fix(admin): protect administrator access
Reject final-admin demotion and deletion at the API boundary. Require explicit confirmation before any administrator role removal.
This commit is contained in:
@@ -38,7 +38,9 @@ public sealed class UsersController : ControllerBase
|
||||
bool EmailConfirmed,
|
||||
string? GoogleEmail,
|
||||
DateTimeOffset? GoogleLinkedAt,
|
||||
List<string> Roles);
|
||||
List<string> Roles,
|
||||
bool IsCurrentUser,
|
||||
bool CanRemoveAdmin);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
|
||||
@@ -46,12 +48,15 @@ public sealed class UsersController : ControllerBase
|
||||
var items = await _users.Users
|
||||
.OrderBy(u => u.Email)
|
||||
.ToListAsync(cancellationToken);
|
||||
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
var adminCount = (await _users.GetUsersInRoleAsync("Admin")).Count;
|
||||
|
||||
var outList = new List<UserDto>(items.Count);
|
||||
foreach (var u in items)
|
||||
{
|
||||
var rs = await _users.GetRolesAsync(u);
|
||||
outList.Add(ToDto(u, rs.ToList()));
|
||||
var roles = rs.ToList();
|
||||
outList.Add(ToDto(u, roles, currentUserId, !roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) || adminCount > 1));
|
||||
}
|
||||
|
||||
return Ok(outList);
|
||||
@@ -93,7 +98,8 @@ public sealed class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
var rs = await _users.GetRolesAsync(u);
|
||||
return Ok(ToDto(u, rs.ToList()));
|
||||
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
return Ok(ToDto(u, rs.ToList(), currentUserId, true));
|
||||
}
|
||||
|
||||
public sealed record SetRolesRequest(string[] Roles);
|
||||
@@ -110,14 +116,32 @@ public sealed class UsersController : ControllerBase
|
||||
var toRemove = current.Where(r => !desired.Contains(r, StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
var toAdd = desired.Where(r => !current.Contains(r, StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (toRemove.Count > 0)
|
||||
await _users.RemoveFromRolesAsync(u, toRemove);
|
||||
if (toRemove.Contains("Admin", StringComparer.OrdinalIgnoreCase)
|
||||
&& (await _users.GetUsersInRoleAsync("Admin")).Count <= 1)
|
||||
{
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "Last administrator protected",
|
||||
Detail = "Assign the Admin role to another user before removing it from the final administrator."
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var r in toAdd)
|
||||
{
|
||||
if (!await _roles.RoleExistsAsync(r))
|
||||
await _roles.CreateAsync(new IdentityRole(r));
|
||||
await _users.AddToRoleAsync(u, r);
|
||||
{
|
||||
var createRole = await _roles.CreateAsync(new IdentityRole(r));
|
||||
if (!createRole.Succeeded) return IdentityFailure(createRole);
|
||||
}
|
||||
|
||||
var addRole = await _users.AddToRoleAsync(u, r);
|
||||
if (!addRole.Succeeded) return IdentityFailure(addRole);
|
||||
}
|
||||
|
||||
if (toRemove.Count > 0)
|
||||
{
|
||||
var removeRoles = await _users.RemoveFromRolesAsync(u, toRemove);
|
||||
if (!removeRoles.Succeeded) return IdentityFailure(removeRoles);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -129,6 +153,16 @@ public sealed class UsersController : ControllerBase
|
||||
var u = await _users.FindByIdAsync(id);
|
||||
if (u is null) return NotFound();
|
||||
|
||||
if (await _users.IsInRoleAsync(u, "Admin")
|
||||
&& (await _users.GetUsersInRoleAsync("Admin")).Count <= 1)
|
||||
{
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "Last administrator protected",
|
||||
Detail = "Assign the Admin role to another user before deleting the final administrator."
|
||||
});
|
||||
}
|
||||
|
||||
var res = await _users.DeleteAsync(u);
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
@@ -201,7 +235,7 @@ public sealed class UsersController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static UserDto ToDto(ApplicationUser user, List<string> roles)
|
||||
private static UserDto ToDto(ApplicationUser user, List<string> roles, string? currentUserId, bool canRemoveAdmin)
|
||||
{
|
||||
return new UserDto(
|
||||
user.Id,
|
||||
@@ -213,7 +247,14 @@ public sealed class UsersController : ControllerBase
|
||||
user.EmailConfirmed,
|
||||
user.GoogleEmail,
|
||||
user.GoogleLinkedAt,
|
||||
roles);
|
||||
roles,
|
||||
string.Equals(user.Id, currentUserId, StringComparison.Ordinal),
|
||||
canRemoveAdmin);
|
||||
}
|
||||
|
||||
private BadRequestObjectResult IdentityFailure(IdentityResult result)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
|
||||
Reference in New Issue
Block a user