Skip to content

Commit c41aaec

Browse files
feat: Add Activity Log feature with Azure Monitor integration
- Implemented ActivityLogController to handle AJAX requests for activity logs. - Created ActivityLogService to query logs from Azure Monitor using LogsQueryClient. - Added ActivityLogCategory enum to categorize log events. - Defined ActivityLogEntry model to represent individual log entries. - Mapped event names to categories in ActivityLogEventMap. - Introduced IActivityLogService interface for service abstraction. - Added Activity Log view with filters for time range, category, and event name. - Enhanced DataTable for activity logs with server-side processing. - Updated navigation to include Activity Log link with appropriate authorization policy. - Added necessary JavaScript for handling filters and AJAX requests in activity log view. - Configured Application Insights resource ID in Terraform for Azure deployment. - Assigned Monitoring Reader role to the web app for Application Insights access.
1 parent 38f9d47 commit c41aaec

20 files changed

Lines changed: 1067 additions & 8 deletions
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using Microsoft.ApplicationInsights;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Newtonsoft.Json;
5+
using XtremeIdiots.Portal.Web.Auth.Constants;
6+
using XtremeIdiots.Portal.Web.Models;
7+
using XtremeIdiots.Portal.Web.Models.ActivityLog;
8+
using XtremeIdiots.Portal.Web.Services;
9+
10+
namespace XtremeIdiots.Portal.Web.ApiControllers;
11+
12+
/// <summary>
13+
/// API controller providing activity log data for DataTables AJAX requests
14+
/// </summary>
15+
[Authorize(Policy = AuthPolicies.AccessActivityLog)]
16+
[Route("User")]
17+
public class ActivityLogController(
18+
IActivityLogService activityLogService,
19+
TelemetryClient telemetryClient,
20+
ILogger<ActivityLogController> logger,
21+
IConfiguration configuration) : BaseApiController(telemetryClient, logger, configuration)
22+
{
23+
private readonly static Dictionary<string, TimeSpan> timeRanges = new()
24+
{
25+
["1h"] = TimeSpan.FromHours(1),
26+
["6h"] = TimeSpan.FromHours(6),
27+
["12h"] = TimeSpan.FromHours(12),
28+
["24h"] = TimeSpan.FromHours(24),
29+
["7d"] = TimeSpan.FromDays(7),
30+
["30d"] = TimeSpan.FromDays(30),
31+
};
32+
33+
/// <summary>
34+
/// Provides AJAX endpoint for retrieving paginated activity log data for DataTables
35+
/// </summary>
36+
[HttpPost("GetActivityLogAjax")]
37+
[ValidateAntiForgeryToken]
38+
public async Task<IActionResult> GetActivityLogAjax(
39+
[FromQuery] string? timeRange,
40+
[FromQuery] string? category,
41+
[FromQuery] string? eventName,
42+
[FromQuery] bool includeReads = false,
43+
CancellationToken cancellationToken = default)
44+
{
45+
return await ExecuteWithErrorHandlingAsync(async () =>
46+
{
47+
var reader = new StreamReader(Request.Body);
48+
var requestBody = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
49+
var model = JsonConvert.DeserializeObject<DataTableAjaxPostModel>(requestBody);
50+
51+
if (model is null)
52+
return BadRequest("Invalid request body");
53+
54+
var timeSpan = timeRanges.GetValueOrDefault(timeRange ?? "24h", TimeSpan.FromHours(24));
55+
56+
ActivityLogCategory? parsedCategory = null;
57+
if (!string.IsNullOrWhiteSpace(category) && Enum.TryParse<ActivityLogCategory>(category, out var cat))
58+
{
59+
parsedCategory = cat;
60+
}
61+
62+
// Determine sort column and direction from DataTable model
63+
var sortColumn = "timestamp";
64+
var sortDirection = "desc";
65+
66+
if (model.Order.Count > 0)
67+
{
68+
var orderItem = model.Order[0];
69+
var columnIndex = orderItem.Column;
70+
71+
if (columnIndex >= 0 && columnIndex < model.Columns.Count)
72+
{
73+
sortColumn = model.Columns[columnIndex].Name ?? "timestamp";
74+
}
75+
76+
sortDirection = orderItem.Dir ?? "desc";
77+
}
78+
79+
var searchTerm = model.Search?.Value;
80+
81+
var result = await activityLogService.QueryEventsAsync(
82+
timeSpan,
83+
parsedCategory,
84+
eventName,
85+
includeReads,
86+
searchTerm,
87+
model.Start,
88+
model.Length,
89+
sortColumn,
90+
sortDirection,
91+
cancellationToken).ConfigureAwait(false);
92+
93+
TrackSuccessTelemetry("ActivityLogQueried", nameof(GetActivityLogAjax), new Dictionary<string, string>
94+
{
95+
{ "TimeRange", timeRange ?? "24h" },
96+
{ "Category", category ?? "All" },
97+
{ "IncludeReads", includeReads.ToString() },
98+
{ "ResultCount", result.Entries.Count.ToString() }
99+
});
100+
101+
return Ok(new
102+
{
103+
model.Draw,
104+
recordsTotal = result.TotalCount,
105+
recordsFiltered = result.FilteredCount,
106+
data = result.Entries
107+
});
108+
}, nameof(GetActivityLogAjax)).ConfigureAwait(false);
109+
}
110+
111+
/// <summary>
112+
/// Returns event names for a given category (used for cascading filter dropdown)
113+
/// </summary>
114+
[HttpGet("GetActivityLogEvents")]
115+
public IActionResult GetActivityLogEvents([FromQuery] string? category, [FromQuery] bool includeReads = false)
116+
{
117+
if (string.IsNullOrWhiteSpace(category) || !Enum.TryParse<ActivityLogCategory>(category, out var parsedCategory))
118+
{
119+
// Return all events matching scope
120+
var allEvents = ActivityLogEventMap.Events
121+
.Where(e => includeReads || e.Value.IsWrite)
122+
.Select(e => new { name = e.Key, category = e.Value.Category.ToString() })
123+
.OrderBy(e => e.name)
124+
.ToList();
125+
126+
return Ok(allEvents);
127+
}
128+
129+
var events = ActivityLogEventMap.GetEventsByCategory(parsedCategory, includeReads)
130+
.Select(e => new { name = e, category = parsedCategory.ToString() })
131+
.ToList();
132+
133+
return Ok(events);
134+
}
135+
}

src/XtremeIdiots.Portal.Web/Auth/Constants/AuthPolicies.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public static class AuthPolicies
9393
public const string AccessStatus = nameof(AccessStatus);
9494

9595
// User management policies
96+
public const string AccessActivityLog = nameof(AccessActivityLog);
9697
public const string AccessUsers = nameof(AccessUsers);
9798
public const string CreateUserClaim = nameof(CreateUserClaim);
9899
public const string DeleteUserClaim = nameof(DeleteUserClaim);

src/XtremeIdiots.Portal.Web/Auth/Handlers/UsersAuthHandler.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public Task HandleAsync(AuthorizationHandlerContext context)
3434
case PerformUserSearch performUserSearch:
3535
HandlePerformUserSearch(context, performUserSearch);
3636
break;
37+
case AccessActivityLog accessActivityLog:
38+
HandleAccessActivityLog(context, accessActivityLog);
39+
break;
3740
default:
3841
break;
3942
}
@@ -75,5 +78,10 @@ private static void HandlePerformUserSearch(AuthorizationHandlerContext context,
7578
BaseAuthorizationHelper.CheckClaimTypes(context, requirement, BaseAuthorizationHelper.ClaimGroups.AllAdminLevels);
7679
}
7780

81+
private static void HandleAccessActivityLog(AuthorizationHandlerContext context, IAuthorizationRequirement requirement)
82+
{
83+
BaseAuthorizationHelper.CheckSeniorAdminAccess(context, requirement);
84+
}
85+
7886
#endregion
7987
}

src/XtremeIdiots.Portal.Web/Auth/Requirements/UsersAuthRequirements.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,11 @@ public class DeleteUserClaim : IAuthorizationRequirement
2828
/// </summary>
2929
public class PerformUserSearch : IAuthorizationRequirement
3030
{
31+
}
32+
33+
/// <summary>
34+
/// Authorization requirement for accessing the activity log
35+
/// </summary>
36+
public class AccessActivityLog : IAuthorizationRequirement
37+
{
3138
}

src/XtremeIdiots.Portal.Web/Controllers/UserController.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ public async Task<IActionResult> Permissions()
6565
}, nameof(Permissions)).ConfigureAwait(false);
6666
}
6767

68+
/// <summary>
69+
/// Displays the activity log page showing Application Insights custom events
70+
/// </summary>
71+
/// <returns>The activity log view</returns>
72+
[HttpGet]
73+
[Authorize(Policy = AuthPolicies.AccessActivityLog)]
74+
public async Task<IActionResult> ActivityLog()
75+
{
76+
return await ExecuteWithErrorHandlingAsync(async () =>
77+
{
78+
await Task.CompletedTask.ConfigureAwait(false);
79+
return View();
80+
}, nameof(ActivityLog)).ConfigureAwait(false);
81+
}
82+
6883
/// <summary>
6984
/// Displays the user profile management page for the specified user
7085
/// </summary>

src/XtremeIdiots.Portal.Web/Extensions/PolicyExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public static void AddXtremeIdiotsPolicies(this AuthorizationOptions options)
8181
options.AddPolicy(AuthPolicies.AccessStatus, policy => policy.Requirements.Add(new AccessStatus()));
8282

8383
options.AddPolicy(AuthPolicies.AccessUsers, policy => policy.Requirements.Add(new AccessUsers()));
84+
options.AddPolicy(AuthPolicies.AccessActivityLog, policy => policy.Requirements.Add(new AccessActivityLog()));
8485
options.AddPolicy(AuthPolicies.CreateUserClaim, policy => policy.Requirements.Add(new CreateUserClaim()));
8586
options.AddPolicy(AuthPolicies.DeleteUserClaim, policy => policy.Requirements.Add(new DeleteUserClaim()));
8687
options.AddPolicy(AuthPolicies.PerformUserSearch, policy => policy.Requirements.Add(new PerformUserSearch()));
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace XtremeIdiots.Portal.Web.Models.ActivityLog;
2+
3+
public enum ActivityLogCategory
4+
{
5+
Authentication,
6+
Authorization,
7+
AdminActions,
8+
PlayerManagement,
9+
GameServers,
10+
Credentials,
11+
BanFileMonitors,
12+
Demos,
13+
Maps,
14+
UserManagement,
15+
Tags,
16+
ProtectedNames,
17+
Chat,
18+
Notifications,
19+
System
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace XtremeIdiots.Portal.Web.Models.ActivityLog;
2+
3+
public class ActivityLogEntry
4+
{
5+
public DateTimeOffset Timestamp { get; set; }
6+
public string EventName { get; set; } = string.Empty;
7+
public string Category { get; set; } = string.Empty;
8+
public string? UserId { get; set; }
9+
public string? Username { get; set; }
10+
public string? Controller { get; set; }
11+
public string? Action { get; set; }
12+
public Dictionary<string, string> Properties { get; set; } = [];
13+
}

0 commit comments

Comments
 (0)