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?
| Benefit | Description |
|---|---|
| Accountability | Admins cannot hide their actions |
| Trust | Users can trust moderation is logged |
| Investigation | Full history for dispute resolution |
| Compliance | Audit 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
| Action | Description | Target Type |
|---|---|---|
DELETE_TOURNAMENT | Removed a tournament | TOURNAMENT |
DELETE_SCORE | Removed a leaderboard entry | SCORE |
VERIFY_SCORE | Upgraded verification level | SCORE |
UNVERIFY_SCORE | Reset verification to SELF_REPORTED | SCORE |
GLOBAL_BAN | Banned user from platform | USER |
GLOBAL_UNBAN | Removed global ban | USER |
AdminTargetType Enum
| Type | Description |
|---|---|
TOURNAMENT | Tournament entity in tournaments collection |
SCORE | Leaderboard entry in leaderboards collection |
USER | User 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 eThis 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
| Filter | Description |
|---|---|
| By action | Only show DELETE_TOURNAMENT logs |
| By time | Only show logs from last 24 hours |
| By admin | Only show logs from specific admin |
| By target | Only 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:
| Approach | Trade-off |
|---|---|
| Keep forever | Storage cost grows, full history available |
| Archive after 1 year | Move to cold storage, still queryable |
| Delete after 2 years | Reduced storage, limited investigation window |
Current policy: Keep forever (storage cost is minimal).
Related Documentation
- global-admin-system - Admin system architecture
- admin-role-assignment - How to assign admin roles
- 2025-12-30-phase5-admin-system - Phase 5 session notes