22using FSH . Framework . Caching ;
33using FSH . Framework . Core . Common ;
44using FSH . Framework . Core . Exceptions ;
5+ using FSH . Framework . Core . Context ;
56using FSH . Framework . Eventing . Outbox ;
67using FSH . Framework . Jobs . Services ;
78using FSH . Framework . Mailing ;
1819using FSH . Modules . Identity . Data ;
1920using FSH . Modules . Identity . Features . v1 . Roles ;
2021using FSH . Modules . Identity . Features . v1 . Users ;
22+ using FSH . Modules . Auditing . Contracts ;
2123using Microsoft . AspNetCore . Http ;
2224using Microsoft . AspNetCore . Identity ;
2325using 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 )
0 commit comments