Admin Audit Trail

The audit trail provides an immutable record of all admin actions. Every moderation action is logged with full context, enabling accountability and dispute investigation.

Overview

Admin Action                        Audit Log Created
     │                                     │
     ▼                                     ▼
┌─────────────────┐              ┌─────────────────────────────┐
│ deleteTournament│    ───►      │ admin_audit_logs/{logId}    │
│ reason: "spam"  │              │ - action: DELETE_TOURNAMENT │
└─────────────────┘              │ - targetId: tournament-123  │
                                 │ - reason: "spam"            │
                                 │ - timestamp: 1735567200000  │
                                 │ - adminId: admin-uid        │
                                 └─────────────────────────────┘

Immutability Guarantee

Audit logs cannot be modified or deleted after creation. This is enforced at the Firestore security rules level:

match /admin_audit_logs/{logId} {
  allow read: if isAdmin();
  allow create: if isAdmin();
  // No update or delete rules = immutable
}

Why Immutable?

BenefitDescription
AccountabilityAdmins cannot hide their actions
TrustUsers can trust moderation is logged
InvestigationFull history for dispute resolution
ComplianceAudit requirements for competitive events

AdminAuditLog Model

Location: shared/domain/src/commonMain/kotlin/.../admin/AdminModels.kt

@Serializable
data class AdminAuditLog(
    val logId: String = "",           // Auto-generated UUID
    val adminId: String = "",         // Admin's Firebase UID
    val action: AdminAction,          // What action was taken
    val targetType: AdminTargetType,  // What type of entity
    val targetId: String = "",        // ID of affected entity
    val metadata: Map<String, String> = emptyMap(),  // Additional context
    val timestamp: Long = 0L,         // When action occurred
    val reason: String = ""           // Human-readable justification
)

Action Types

AdminAction Enum

ActionDescriptionTarget Type
DELETE_TOURNAMENTRemoved a tournamentTOURNAMENT
DELETE_SCORERemoved a leaderboard entrySCORE
VERIFY_SCOREUpgraded verification levelSCORE
UNVERIFY_SCOREReset verification to SELF_REPORTEDSCORE
GLOBAL_BANBanned user from platformUSER
GLOBAL_UNBANRemoved global banUSER

AdminTargetType Enum

TypeDescription
TOURNAMENTTournament entity in tournaments collection
SCORELeaderboard entry in leaderboards collection
USERUser account (Firebase UID)

Firestore Schema

Collection Path

admin_audit_logs/{logId}

Document Structure

{
  "logId": "abc123-def456-ghi789",
  "adminId": "firebase-uid-of-admin",
  "action": "DELETE_TOURNAMENT",
  "targetType": "TOURNAMENT",
  "targetId": "tournament-123",
  "metadata": {
    "tournamentName": "Weekend Shoot",
    "creatorId": "original-creator-uid",
    "participantCount": "12"
  },
  "timestamp": 1735567200000,
  "reason": "Duplicate entry - user created multiple identical tournaments"
}

Metadata Examples

Tournament Deletion:

{
  "tournamentName": "Saturday Shoot",
  "creatorId": "user-456",
  "participantCount": "8",
  "status": "ACTIVE"
}

Score Verification:

{
  "previousLevel": "SELF_REPORTED",
  "newLevel": "ADMIN_VERIFIED",
  "score": "285",
  "userId": "archer-789"
}

User Ban:

{
  "displayName": "SuspiciousUser",
  "previousViolations": "3"
}

Logging Implementation

Automatic Logging

All admin operations automatically log an audit event. The logAuditEvent function is called after every successful operation:

private suspend fun logAuditEvent(
    adminId: String,
    action: AdminAction,
    targetType: AdminTargetType,
    targetId: String,
    reason: String,
    metadata: Map<String, String> = emptyMap()
) {
    val docRef = firestore.collection("admin_audit_logs").document()
    val now = System.currentTimeMillis()
 
    val auditData = mapOf(
        "logId" to docRef.id,
        "adminId" to adminId,
        "action" to action.name,
        "targetType" to targetType.name,
        "targetId" to targetId,
        "metadata" to metadata,
        "timestamp" to now,
        "reason" to reason
    )
 
    docRef.set(auditData).await()
}

Failure Handling

If audit logging fails, the parent operation also fails:

// Re-throw to fail the parent operation - audit logging is critical
throw e

This ensures no admin action can succeed without being logged.


Querying Audit Logs

Basic Query

suspend fun getAuditLogs(
    limit: Int = 50,
    action: AdminAction? = null
): List<AdminAuditLog>

Firestore Query

var query: Query = firestore.collection("admin_audit_logs")
    .orderBy("timestamp", Query.Direction.DESCENDING)
 
if (action != null) {
    query = query.whereEqualTo("action", action.name)
}
 
query = query.limit(limit.toLong())

Filtering Options

FilterDescription
By actionOnly show DELETE_TOURNAMENT logs
By timeOnly show logs from last 24 hours
By adminOnly show logs from specific admin
By targetOnly show logs affecting specific entity

UI Display

Android (Compose)

@Composable
fun AuditLogList(logs: List<AdminAuditLog>) {
    LazyColumn {
        items(logs) { log ->
            AuditLogItem(
                action = log.action.displayName,
                targetType = log.targetType.name,
                targetId = log.targetId,
                reason = log.reason,
                timestamp = formatTimestamp(log.timestamp),
                adminId = log.adminId
            )
        }
    }
}

iOS (SwiftUI)

List(viewModel.auditLogs) { log in
    AuditLogRow(
        action: log.action.rawValue,
        targetType: log.targetType.rawValue,
        targetId: log.targetId,
        reason: log.reason,
        timestamp: log.timestamp,
        adminId: log.adminId
    )
}

Statistics

AdminStats

The audit trail powers dashboard statistics:

data class AdminStats(
    val totalBannedUsers: Int,
    val recentAuditEvents: Int  // Last 24 hours
)

Recent Events Query (iOS)

let oneDayAgo = Date().addingTimeInterval(-24 * 60 * 60)
let snapshot = try await db.collection("admin_audit_logs")
    .whereField("timestamp", isGreaterThan: Int64(oneDayAgo.timeIntervalSince1970 * 1000))
    .getDocuments()

Best Practices

Reason Field

Always provide a clear, human-readable reason:

❌ Bad✅ Good
”spam""User created 5 duplicate tournaments in 1 hour"
"cheating""Score of 300 impossible on 18m round - witness verification failed"
"banned""Repeated harassment of other users after 2 warnings”

Metadata

Include relevant context that may not be available later:

logAuditEvent(
    action = AdminAction.DELETE_TOURNAMENT,
    targetId = tournamentId,
    reason = reason,
    metadata = mapOf(
        "tournamentName" to tournament.name,
        "creatorId" to tournament.creatorId,
        "participantCount" to tournament.participants.size.toString(),
        "status" to tournament.status.name
    )
)

Retention

Currently, audit logs are retained indefinitely. Future considerations:

ApproachTrade-off
Keep foreverStorage cost grows, full history available
Archive after 1 yearMove to cold storage, still queryable
Delete after 2 yearsReduced storage, limited investigation window

Current policy: Keep forever (storage cost is minimal).