Initial import into Gitea
This commit is contained in:
119
Management/Security/ActivityLoggingMiddleware.cs
Normal file
119
Management/Security/ActivityLoggingMiddleware.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Management.Data;
|
||||
using Management.Security;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Management.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Logs all mutating (non-GET) requests to tbAdminActivity when a staff JWT is present.
|
||||
///
|
||||
/// Enables request body buffering so a compact filter summary can be extracted
|
||||
/// after the controller has run. Fire-and-forget — never delays the response.
|
||||
/// Registered after ClientAuthMiddleware so ClientContext is populated.
|
||||
/// </summary>
|
||||
public sealed class ActivityLoggingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ActivityLoggingMiddleware> _logger;
|
||||
|
||||
private static readonly string[] _skipPrefixes =
|
||||
{
|
||||
"/api/help", "/api/test", "/swagger", "/health"
|
||||
};
|
||||
|
||||
// Body keys treated as filter context worth surfacing in the activity log.
|
||||
// Pagination keys (page, pageSize) are intentionally excluded.
|
||||
private static readonly string[] _filterKeys =
|
||||
[
|
||||
"search", "status", "clientId", "category", "network",
|
||||
"dateFrom", "dateTo", "action", "ruleId", "initiativeId",
|
||||
];
|
||||
|
||||
public ActivityLoggingMiddleware(RequestDelegate next, ILogger<ActivityLoggingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
var method = context.Request.Method;
|
||||
var isRead = HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
|
||||
|
||||
// Buffer request body before calling next so we can re-read it after
|
||||
if (!isRead)
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await _next(context);
|
||||
|
||||
if (isRead) return;
|
||||
|
||||
var oid = clientContext.ClientId;
|
||||
if (string.IsNullOrWhiteSpace(oid) || clientContext.IsDevBypass) return;
|
||||
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
if (_skipPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
var statusCode = context.Response.StatusCode;
|
||||
var filterSummary = await ReadFilterSummaryAsync(context.Request);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
oid,
|
||||
email = clientContext.Email,
|
||||
displayName = clientContext.ClientName,
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
filter = filterSummary, // null for bodies with no recognised filter keys
|
||||
});
|
||||
|
||||
await sql.ExecProcAsync("dbo.spAdminActivity", "log", rqst);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[Activity] Log failed: {Message}", ex.Message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string?> ReadFilterSummaryAsync(HttpRequest request)
|
||||
{
|
||||
if (request.ContentLength is null or 0) return null;
|
||||
if (request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) != true) return null;
|
||||
|
||||
try
|
||||
{
|
||||
request.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var parts = new List<string>();
|
||||
|
||||
foreach (var key in _filterKeys)
|
||||
{
|
||||
if (!doc.RootElement.TryGetProperty(key, out var val)) continue;
|
||||
if (val.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) continue;
|
||||
|
||||
var str = val.ValueKind == JsonValueKind.String
|
||||
? val.GetString()
|
||||
: val.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(str))
|
||||
parts.Add($"{key}={str}");
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(" ", parts) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,18 @@ namespace Management.Security;
|
||||
/// Authentication middleware for Management API.
|
||||
///
|
||||
/// Auth paths:
|
||||
/// - /api/onboarding/* → JWT (user may not have session yet)
|
||||
/// - /api/admin/* → Session + Admin role
|
||||
/// - /api/monitoring/* → Session + Admin role
|
||||
/// - /api/test/* → Anonymous
|
||||
/// - /api/onboarding/* → JWT (Entra, any staff role)
|
||||
/// - /api/monitoring/* → JWT (Entra, Staff.Admin or Staff.Tech role)
|
||||
/// - /api/admin/* → JWT (Entra, Staff.Admin role only)
|
||||
/// - /api/registration/*→ JWT (Entra, Staff.Admin role only)
|
||||
/// - /api/staff/* → JWT (Entra, Staff.Admin role only)
|
||||
/// - /api/test/* → Anonymous
|
||||
/// - /api/documents/* → JWT (Entra, Staff.Admin or Staff.Tech role)
|
||||
/// - /api/help/* → Anonymous
|
||||
///
|
||||
/// App Role values (defined in Entra portal on the staff app registration):
|
||||
/// Staff.Admin → full platform access
|
||||
/// Staff.Tech → monitoring/health only
|
||||
/// </summary>
|
||||
public sealed class ClientAuthMiddleware
|
||||
{
|
||||
@@ -28,9 +36,10 @@ public sealed class ClientAuthMiddleware
|
||||
"/", "/health"
|
||||
};
|
||||
|
||||
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
|
||||
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test", "/api/help" };
|
||||
private static readonly string[] _jwtOnlyPrefixes = { "/api/onboarding" };
|
||||
private static readonly string[] _adminRequiredPrefixes = { "/api/monitoring", "/api/admin" };
|
||||
private static readonly string[] _staffRequiredPrefixes = { "/api/monitoring", "/api/documents" }; // Admin or Tech
|
||||
private static readonly string[] _adminRequiredPrefixes = { "/api/admin", "/api/registration", "/api/staff" }; // Admin only
|
||||
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
|
||||
private static readonly object _oidcLock = new();
|
||||
@@ -75,10 +84,36 @@ public sealed class ClientAuthMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin-required paths
|
||||
// Staff-required paths (Admin or Tech role) — monitoring/health
|
||||
// Try session auth (client session tokens for CIAM users).
|
||||
// then fall back to direct JWT for service-to-service calls.
|
||||
if (IsStaffRequiredPath(path))
|
||||
{
|
||||
var staffAuthed = await TrySessionAuthAsync(context, clientContext, sql)
|
||||
|| await TryJwtAuthAsync(context, clientContext);
|
||||
if (staffAuthed)
|
||||
{
|
||||
if (!clientContext.IsStaff)
|
||||
{
|
||||
context.Response.StatusCode = 403;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Staff access required" });
|
||||
return;
|
||||
}
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid staff authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin-required paths (Admin role only)
|
||||
if (IsAdminRequiredPath(path))
|
||||
{
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql))
|
||||
var adminAuthed = await TrySessionAuthAsync(context, clientContext, sql)
|
||||
|| await TryJwtAuthAsync(context, clientContext);
|
||||
if (adminAuthed)
|
||||
{
|
||||
if (!clientContext.IsAdmin)
|
||||
{
|
||||
@@ -86,13 +121,12 @@ public sealed class ClientAuthMiddleware
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Admin access required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin session required" });
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,6 +147,9 @@ public sealed class ClientAuthMiddleware
|
||||
private static bool IsJwtOnlyPath(string path) =>
|
||||
_jwtOnlyPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsStaffRequiredPath(string path) =>
|
||||
_staffRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsAdminRequiredPath(string path) =>
|
||||
_adminRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -164,8 +201,9 @@ public sealed class ClientAuthMiddleware
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var validateProc = "dbo.spClientSession"; // Staff use JWT Bearer; only client sessions exist
|
||||
var resp = await sql.ExecProcAsync(validateProc, "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return false;
|
||||
@@ -178,12 +216,20 @@ public sealed class ClientAuthMiddleware
|
||||
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
|
||||
|
||||
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.PlatformClientId = clientContext.ClientId;
|
||||
|
||||
// Admin sessions return adminId; client sessions return clientId — handle both
|
||||
var clientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
clientId = data.TryGetProperty("adminId", out var aid) ? aid.GetString() : null;
|
||||
clientContext.ClientId = clientId;
|
||||
clientContext.PlatformClientId = clientId;
|
||||
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
|
||||
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
|
||||
// IsStaff is computed from Role in ClientContext — no assignment needed
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
@@ -207,9 +253,9 @@ public sealed class ClientAuthMiddleware
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return false;
|
||||
|
||||
var tenantId = _config["Auth:EntraId:TenantId"];
|
||||
var clientId = _config["Auth:EntraId:ClientId"];
|
||||
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
|
||||
var tenantId = _config["Auth:Staff:TenantId"];
|
||||
var clientId = _config["Auth:Staff:ClientId"];
|
||||
var instance = _config["Auth:Staff:Instance"] ?? "https://usimclients.ciamlogin.com/";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
|
||||
return false;
|
||||
@@ -217,28 +263,44 @@ public sealed class ClientAuthMiddleware
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
|
||||
// Disable default claim type remapping so JWT claim names (roles, oid, etc.)
|
||||
// are preserved as-is. Without this, "roles" is remapped to ClaimTypes.Role
|
||||
// and FindAll("roles") returns empty — causing IsAdmin to be false.
|
||||
handler.InboundClaimTypeMap.Clear();
|
||||
|
||||
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
|
||||
var metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
|
||||
var mgr = GetOrCreateConfigManager(metadataAddress);
|
||||
var mgr = GetOrCreateConfigManager(metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId, $"api://{clientId}" },
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId, $"api://{clientId}" },
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = handler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email);
|
||||
clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name);
|
||||
// Map Entra App Role values to internal role names.
|
||||
// Users with no recognized role get null — middleware rejects them.
|
||||
var roles = principal.FindAll("roles").Select(c => c.Value).ToList();
|
||||
var role = roles.Contains("Staff.Admin") ? "Admin"
|
||||
: roles.Contains("Staff.Tech") ? "Tech"
|
||||
: null; // no valid role assigned — reject
|
||||
|
||||
clientContext.ClientId = principal.FindFirstValue("oid")
|
||||
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
clientContext.Email = principal.FindFirstValue("preferred_username")
|
||||
?? principal.FindFirstValue(ClaimTypes.Email);
|
||||
clientContext.ClientName = principal.FindFirstValue("name")
|
||||
?? principal.FindFirstValue(ClaimTypes.Name);
|
||||
clientContext.Role = role;
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
|
||||
@@ -16,5 +16,17 @@ public sealed class ClientContext
|
||||
public bool IsDevBypass { get; set; }
|
||||
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
public bool IsAdmin => string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Full platform access.</summary>
|
||||
/// <summary>Full admin access — SuperAdmin or Admin role.</summary>
|
||||
public bool IsAdmin =>
|
||||
string.Equals(Role, "SuperAdmin", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(Role, "Admin", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Health monitoring and Tech Client access only.</summary>
|
||||
public bool IsTech =>
|
||||
string.Equals(Role, "Tech", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Any authenticated staff member (SuperAdmin, Admin or Tech).</summary>
|
||||
public bool IsStaff => IsAdmin || IsTech;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user