Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions internal/constants/audit_event.go
Original file line number Diff line number Diff line change
@@ -0,0 +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 (
// 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"

// 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"
// 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"
// 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"

// 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 is logged when a token is revoked.
AuditTokenRevokedEvent = "token.revoked"

// AuditSessionCreatedEvent is logged when a new session is created.
AuditSessionCreatedEvent = "session.created"
// AuditSessionTerminatedEvent is logged when a session is terminated.
AuditSessionTerminatedEvent = "session.terminated"
)
138 changes: 138 additions & 0 deletions internal/integration_tests/audit_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package integration_tests

import (
"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 := createContext(ts)

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 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: uniqueAction,
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": uniqueAction,
})
require.NoError(t, err)
assert.Equal(t, 0, len(logs))
})
}
Binary file not shown.
103 changes: 103 additions & 0 deletions internal/storage/db/arangodb/audit_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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
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
}
25 changes: 25 additions & 0 deletions internal/storage/db/arangodb/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading