fix(admin): protect administrator access
CI and Deploy / test (pull_request) Failing after 4m31s
CI and Deploy / deploy (pull_request) Has been skipped

Reject final-admin demotion and deletion at the API boundary. Require explicit confirmation before any administrator role removal.
This commit is contained in:
cesnimda
2026-08-15 13:11:54 +02:00
parent 896746319b
commit 15e5464da7
9 changed files with 411 additions and 45 deletions
+50 -9
View File
@@ -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)