Skip to content

Commit e1da827

Browse files
committed
Tenant user deactivation hardening & user UI
- Enforce backend guardrails: block self-deactivation, admin-on-admin deactivation, and ensure at least one active admin per tenant in `UserService.ToggleStatusAsync` - Add audit logging for all deactivation attempts (success/failure) - Add Blazor user management pages with matching frontend guardrails - Update navigation to include Users page - Bump System.IdentityModel.Tokens.Jwt to 8.15.0; add to Blazor project - Document deactivation rules and rationale in knowledge base - Minor Blazor project and analyzer suppressions update
1 parent 6ff714b commit e1da827

File tree

6 files changed

+768
-8
lines changed

6 files changed

+768
-8
lines changed

src/Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.16.1.129956" />
8585
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
8686
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
87-
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
87+
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
8888
<PackageVersion Include="Shouldly" Version="4.3.0" />
8989
<PackageVersion Include="xunit" Version="2.9.3" />
9090
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
@@ -97,4 +97,4 @@
9797
<ItemGroup Label="AWS">
9898
<PackageVersion Include="AWSSDK.S3" Version="3.7.405.0" />
9999
</ItemGroup>
100-
</Project>
100+
</Project>

src/Modules/Identity/Modules.Identity/Services/UserService.cs

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using FSH.Framework.Caching;
33
using FSH.Framework.Core.Common;
44
using FSH.Framework.Core.Exceptions;
5+
using FSH.Framework.Core.Context;
56
using FSH.Framework.Eventing.Outbox;
67
using FSH.Framework.Jobs.Services;
78
using FSH.Framework.Mailing;
@@ -18,6 +19,7 @@
1819
using FSH.Modules.Identity.Data;
1920
using FSH.Modules.Identity.Features.v1.Roles;
2021
using FSH.Modules.Identity.Features.v1.Users;
22+
using FSH.Modules.Auditing.Contracts;
2123
using Microsoft.AspNetCore.Http;
2224
using Microsoft.AspNetCore.Identity;
2325
using Microsoft.Extensions.Options;
@@ -42,11 +44,15 @@ internal sealed partial class UserService(
4244
IStorageService storageService,
4345
IOutboxStore outboxStore,
4446
IOptions<OriginOptions> originOptions,
45-
IHttpContextAccessor httpContextAccessor
47+
IHttpContextAccessor httpContextAccessor,
48+
ICurrentUser currentUser,
49+
IAuditClient auditClient
4650
) : IUserService
4751
{
4852
private readonly Uri? _originUrl = originOptions.Value.OriginUrl;
4953
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor;
54+
private readonly ICurrentUser _currentUser = currentUser;
55+
private readonly IAuditClient _auditClient = auditClient;
5056

5157
private void EnsureValidTenant()
5258
{
@@ -207,19 +213,94 @@ public async Task<string> RegisterAsync(string firstName, string lastName, strin
207213

208214
public async Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken)
209215
{
210-
var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken);
216+
EnsureValidTenant();
217+
218+
var actorId = _currentUser.GetUserId();
219+
if (actorId == Guid.Empty)
220+
{
221+
throw new UnauthorizedException("authenticated user required to toggle status");
222+
}
223+
224+
var actor = await userManager.FindByIdAsync(actorId.ToString());
225+
_ = actor ?? throw new UnauthorizedException("current user not found");
226+
227+
async ValueTask AuditPolicyFailureAsync(string reason, CancellationToken ct)
228+
{
229+
var tenant = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id ?? "unknown";
230+
var claims = new Dictionary<string, object?>
231+
{
232+
["actorId"] = actorId.ToString(),
233+
["targetUserId"] = userId,
234+
["tenant"] = tenant,
235+
["action"] = activateUser ? "activate" : "deactivate"
236+
};
237+
238+
await _auditClient.WriteSecurityAsync(
239+
SecurityAction.PolicyFailed,
240+
subjectId: actorId.ToString(),
241+
reasonCode: reason,
242+
claims: claims,
243+
severity: AuditSeverity.Warning,
244+
source: "Identity",
245+
ct: ct).ConfigureAwait(false);
246+
}
247+
248+
if (!await userManager.IsInRoleAsync(actor, RoleConstants.Admin))
249+
{
250+
await AuditPolicyFailureAsync("ActorNotAdmin", cancellationToken);
251+
throw new CustomException("Only administrators can toggle user status.");
252+
}
253+
254+
if (!activateUser && string.Equals(actor.Id, userId, StringComparison.Ordinal))
255+
{
256+
await AuditPolicyFailureAsync("SelfDeactivationBlocked", cancellationToken);
257+
throw new CustomException("Users cannot deactivate themselves.");
258+
}
211259

260+
var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken);
212261
_ = user ?? throw new NotFoundException("User Not Found.");
213262

214-
bool isAdmin = await userManager.IsInRoleAsync(user, RoleConstants.Admin);
215-
if (isAdmin)
263+
bool targetIsAdmin = await userManager.IsInRoleAsync(user, RoleConstants.Admin);
264+
if (targetIsAdmin)
216265
{
217-
throw new CustomException("Administrators Profile's Status cannot be toggled");
266+
await AuditPolicyFailureAsync("AdminDeactivationBlocked", cancellationToken);
267+
throw new CustomException("Administrators cannot be deactivated.");
268+
}
269+
270+
if (!activateUser)
271+
{
272+
var activeAdmins = await userManager.GetUsersInRoleAsync(RoleConstants.Admin);
273+
int activeAdminCount = activeAdmins.Count(u => u.IsActive);
274+
if (activeAdminCount == 0)
275+
{
276+
await AuditPolicyFailureAsync("NoActiveAdmins", cancellationToken);
277+
throw new CustomException("Tenant must have at least one active administrator.");
278+
}
218279
}
219280

220281
user.IsActive = activateUser;
221282

222-
await userManager.UpdateAsync(user);
283+
var result = await userManager.UpdateAsync(user);
284+
if (!result.Succeeded)
285+
{
286+
var errors = result.Errors.Select(error => error.Description).ToList();
287+
throw new CustomException("Toggle status failed", errors);
288+
}
289+
290+
var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id ?? "unknown";
291+
await _auditClient.WriteActivityAsync(
292+
ActivityKind.Command,
293+
name: "ToggleUserStatus",
294+
statusCode: 204,
295+
durationMs: 0,
296+
captured: BodyCapture.None,
297+
requestSize: 0,
298+
responseSize: 0,
299+
requestPreview: new { actorId = actorId.ToString(), targetUserId = userId, action = activateUser ? "activate" : "deactivate", tenant = tenantId },
300+
responsePreview: new { outcome = "success" },
301+
severity: AuditSeverity.Information,
302+
source: "Identity",
303+
ct: cancellationToken).ConfigureAwait(false);
223304
}
224305

225306
public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage)

src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
<MudNavMenu>
33
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink>
44
<MudNavLink Href="/profile" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Person">Profile</MudNavLink>
5+
<MudNavLink Href="/users" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Group">Users</MudNavLink>
56
<MudNavLink Href="/audits" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">Audits</MudNavLink>
67
</MudNavMenu>

0 commit comments

Comments
 (0)