Repair first-time identity and trust operator journeys
This commit is contained in:
@@ -156,6 +156,105 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Equal("invalid_email", payload!.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUser_DisableUser_AndEnableUser_PersistLifecycleAndRoles()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 40, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var roles = new InMemoryRoleRepository();
|
||||
|
||||
await roles.CreateAsync("default", new RoleEntity
|
||||
{
|
||||
Id = Guid.Parse("a9e2fd75-df4e-4485-b23f-1ec573f59138"),
|
||||
TenantId = "default",
|
||||
Name = "role/console-viewer",
|
||||
DisplayName = "Console Viewer",
|
||||
Description = "Read-only console access",
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-5),
|
||||
UpdatedAt = now.AddDays(-5),
|
||||
});
|
||||
await roles.CreateAsync("default", new RoleEntity
|
||||
{
|
||||
Id = Guid.Parse("f526b104-24f7-46f1-ab52-3c3dfd95cb0c"),
|
||||
TenantId = "default",
|
||||
Name = "role/release-operator",
|
||||
DisplayName = "Release Operator",
|
||||
Description = "Operate releases",
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-5),
|
||||
UpdatedAt = now.AddDays(-5),
|
||||
});
|
||||
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, users, roleRepository: roles);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityUsersRead,
|
||||
StellaOpsScopes.AuthorityUsersWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "operator.alice",
|
||||
email = "operator.alice@example.com",
|
||||
displayName = "Operator Alice",
|
||||
roles = new[] { "role/console-viewer" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(created);
|
||||
|
||||
var updateResponse = await client.PatchAsJsonAsync(
|
||||
$"/console/admin/users/{created!.Id}",
|
||||
new
|
||||
{
|
||||
displayName = "Release Operator Alice",
|
||||
roles = new[] { "role/release-operator" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Release Operator Alice", updated!.DisplayName);
|
||||
Assert.Equal(new[] { "role/release-operator" }, updated.Roles);
|
||||
|
||||
var disableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/disable", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
|
||||
var disabled = await disableResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(disabled);
|
||||
Assert.Equal("disabled", disabled!.Status);
|
||||
|
||||
var enableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/enable", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, enableResponse.StatusCode);
|
||||
var enabled = await enableResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(enabled);
|
||||
Assert.Equal("active", enabled!.Status);
|
||||
|
||||
var listResponse = await client.GetAsync("/console/admin/users");
|
||||
var payload = await listResponse.Content.ReadFromJsonAsync<UserListPayload>();
|
||||
Assert.NotNull(payload);
|
||||
var listed = Assert.Single(payload!.Users.Where(static user => user.Username == "operator.alice"));
|
||||
Assert.Equal("Release Operator Alice", listed.DisplayName);
|
||||
Assert.Equal(new[] { "role/release-operator" }, listed.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesList_ExposesNamedDefaults_AndCreateRolePersists()
|
||||
{
|
||||
@@ -216,6 +315,90 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Contains(reloaded!.Roles, role => role.Name == "security-analyst");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRole_RefreshesScopes_AndPreviewImpactCountsAffectedUsers()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 55, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var roles = new InMemoryRoleRepository();
|
||||
var permissions = new InMemoryPermissionRepository();
|
||||
|
||||
await using var app = await CreateApplicationAsync(
|
||||
timeProvider,
|
||||
sink,
|
||||
users,
|
||||
roleRepository: roles,
|
||||
permissionRepository: permissions);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityUsersWrite,
|
||||
StellaOpsScopes.AuthorityRolesRead,
|
||||
StellaOpsScopes.AuthorityRolesWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var createRoleResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/roles",
|
||||
new
|
||||
{
|
||||
roleId = "security-analyst",
|
||||
displayName = "Security Analyst",
|
||||
scopes = new[] { "findings:read", "vex:read" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createRoleResponse.StatusCode);
|
||||
var createdRole = await createRoleResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(createdRole);
|
||||
|
||||
var createUserResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "security.alice",
|
||||
email = "security.alice@example.com",
|
||||
displayName = "Security Alice",
|
||||
roles = new[] { "security-analyst" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createUserResponse.StatusCode);
|
||||
|
||||
var updateResponse = await client.PatchAsJsonAsync(
|
||||
$"/console/admin/roles/{createdRole!.Id}",
|
||||
new
|
||||
{
|
||||
displayName = "Security Analyst Plus",
|
||||
scopes = new[] { "findings:read", "vex:read", "vuln:investigate" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("security-analyst", updated!.Name);
|
||||
Assert.Equal("Security Analyst Plus", updated.Description);
|
||||
Assert.Contains("vuln:investigate", updated.Permissions);
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync(
|
||||
$"/console/admin/roles/{createdRole.Id}/preview-impact",
|
||||
new { });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<RoleImpactPayload>();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Equal(1, preview!.AffectedUsers);
|
||||
Assert.Equal(0, preview.AffectedClients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantsList_MergesCatalog_AndCreateTenantPersists()
|
||||
{
|
||||
@@ -908,7 +1091,17 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
lock (_sync)
|
||||
{
|
||||
var index = _users.FindIndex(existing => existing.TenantId == user.TenantId && existing.Id == user.Id);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_users[index] = user;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -1235,6 +1428,7 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
|
||||
private sealed record RoleListPayload(IReadOnlyList<RoleSummary> Roles, int Count);
|
||||
private sealed record TenantListPayload(IReadOnlyList<TenantSummary> Tenants, int Count);
|
||||
private sealed record RoleImpactPayload(int AffectedUsers, int AffectedClients, string? Message);
|
||||
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
|
||||
private sealed record ClientSummary(
|
||||
string ClientId,
|
||||
|
||||
@@ -593,10 +593,86 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
UpdateUserRequest request,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedRoles = request.Roles is null ? null : NormalizeRoles(request.Roles);
|
||||
IReadOnlyList<RoleEntity>? resolvedRoles = null;
|
||||
if (updatedRoles is not null)
|
||||
{
|
||||
resolvedRoles = await ResolveRequestedRolesAsync(tenantId, updatedRoles, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var missingRoles = updatedRoles
|
||||
.Except(resolvedRoles.Select(static role => role.Name), StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (missingRoles.Length > 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "roles_not_found", roles = missingRoles });
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = NormalizeOptional(request.DisplayName) ?? user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = user.Enabled,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = updatedRoles is null ? user.Metadata : UpdateUserMetadata(user.Metadata, updatedRoles),
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to persist user updates.");
|
||||
}
|
||||
|
||||
if (updatedRoles is not null && resolvedRoles is not null)
|
||||
{
|
||||
var currentRoles = await roleRepository.GetUserRolesAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var role in currentRoles.Where(role => !updatedRoles.Contains(role.Name, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await roleRepository.RemoveFromUserAsync(tenantId, user.Id, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var grantedBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
|
||||
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
|
||||
?? "console-admin";
|
||||
|
||||
foreach (var role in resolvedRoles.Where(role => !currentRoles.Any(existing => existing.Id == role.Id)))
|
||||
{
|
||||
await roleRepository.AssignToUserAsync(tenantId, user.Id, role.Id, grantedBy, expiresAt: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var effectiveRoles = updatedRoles ?? await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var summary = ToAdminUserSummary(updatedUser, now, effectiveRoles);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -604,19 +680,64 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.update",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User update: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DisableUser(
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = false,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = user.Metadata,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to disable user.");
|
||||
}
|
||||
|
||||
var summary = ToAdminUserSummary(
|
||||
updatedUser,
|
||||
timeProvider.GetUtcNow(),
|
||||
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -624,19 +745,64 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.disable",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User disable: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> EnableUser(
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = true,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = 0,
|
||||
LockedUntil = null,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = user.Metadata,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to enable user.");
|
||||
}
|
||||
|
||||
var summary = ToAdminUserSummary(
|
||||
updatedUser,
|
||||
timeProvider.GetUtcNow(),
|
||||
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -644,10 +810,10 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.enable",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User enable: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
// ========== ROLE ENDPOINTS ==========
|
||||
@@ -796,10 +962,101 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
string roleId,
|
||||
UpdateRoleRequest request,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (role is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "role_not_found", roleId });
|
||||
}
|
||||
|
||||
if (role.IsSystem)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = "built_in_role_read_only",
|
||||
roleId = role.Name,
|
||||
message = "Built-in roles cannot be edited from the setup console. Create a custom role instead.",
|
||||
});
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? normalizedScopes = null;
|
||||
if (request.Scopes is not null)
|
||||
{
|
||||
normalizedScopes = NormalizeScopes(request.Scopes);
|
||||
if (normalizedScopes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "scopes_required" });
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedRole = new RoleEntity
|
||||
{
|
||||
Id = role.Id,
|
||||
TenantId = role.TenantId,
|
||||
Name = role.Name,
|
||||
DisplayName = NormalizeOptional(request.DisplayName) ?? role.DisplayName,
|
||||
Description = NormalizeOptional(request.DisplayName) ?? role.Description ?? role.DisplayName,
|
||||
IsSystem = role.IsSystem,
|
||||
Metadata = role.Metadata,
|
||||
CreatedAt = role.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
await roleRepository.UpdateAsync(tenantId, updatedRole, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<string> effectiveScopes;
|
||||
if (normalizedScopes is not null)
|
||||
{
|
||||
var currentPermissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
var currentByName = currentPermissions.ToDictionary(static permission => permission.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var permission in currentPermissions.Where(permission => !normalizedScopes.Contains(permission.Name, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await permissionRepository.RemoveFromRoleAsync(tenantId, role.Id, permission.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var scope in normalizedScopes.Where(scope => !currentByName.ContainsKey(scope)))
|
||||
{
|
||||
var permission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
|
||||
var permissionId = permission?.Id ?? await permissionRepository.CreateAsync(tenantId, new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = scope,
|
||||
Resource = ExtractPermissionResource(scope),
|
||||
Action = ExtractPermissionAction(scope),
|
||||
Description = $"Console-admin maintained scope '{scope}'.",
|
||||
CreatedAt = now,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await permissionRepository.AssignToRoleAsync(tenantId, role.Id, permissionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
effectiveScopes = normalizedScopes;
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveScopes = (await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false))
|
||||
.Select(static permission => permission.Name)
|
||||
.OrderBy(static scope => scope, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var summary = new AdminRoleSummary(
|
||||
Id: updatedRole.Id.ToString("N"),
|
||||
Name: updatedRole.Name,
|
||||
Description: string.IsNullOrWhiteSpace(updatedRole.Description) ? updatedRole.Name : updatedRole.Description!,
|
||||
Permissions: effectiveScopes,
|
||||
UserCount: 0,
|
||||
IsBuiltIn: false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -807,19 +1064,44 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.update",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", roleId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", updatedRole.Name)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Role update: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewRoleImpact(
|
||||
HttpContext httpContext,
|
||||
string roleId,
|
||||
IRoleRepository roleRepository,
|
||||
IUserRepository userRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (role is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "role_not_found", roleId });
|
||||
}
|
||||
|
||||
var users = await userRepository.GetAllAsync(
|
||||
tenantId,
|
||||
enabled: null,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var affectedUsers = 0;
|
||||
foreach (var user in users)
|
||||
{
|
||||
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (roles.Contains(role.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
affectedUsers++;
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -827,10 +1109,17 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.preview",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", roleId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", role.Name)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { affectedUsers = 0, affectedClients = 0, message = "Impact preview: implementation pending" });
|
||||
return Results.Ok(new
|
||||
{
|
||||
affectedUsers,
|
||||
affectedClients = 0,
|
||||
message = affectedUsers == 0
|
||||
? "No users are currently assigned to this role."
|
||||
: $"{affectedUsers} user{(affectedUsers == 1 ? string.Empty : "s")} would be affected by changes to this role.",
|
||||
});
|
||||
}
|
||||
|
||||
// ========== CLIENT ENDPOINTS ==========
|
||||
@@ -1260,6 +1549,74 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
return "default";
|
||||
}
|
||||
|
||||
private static async Task<UserEntity?> ResolveUserEntityAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedId))
|
||||
{
|
||||
var byGuid = await userRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
|
||||
if (byGuid is not null)
|
||||
{
|
||||
return byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
var bySubject = await userRepository.GetBySubjectIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (bySubject is not null)
|
||||
{
|
||||
return bySubject;
|
||||
}
|
||||
|
||||
return await userRepository.GetByUsernameAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<RoleEntity?> ResolveRoleEntityAsync(
|
||||
string tenantId,
|
||||
string roleId,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Guid.TryParse(roleId, out var parsedId))
|
||||
{
|
||||
var byGuid = await roleRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
|
||||
if (byGuid is not null)
|
||||
{
|
||||
return byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
return await roleRepository.GetByNameAsync(tenantId, roleId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<RoleEntity>> ResolveRequestedRolesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> requestedRoles,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolved = new List<RoleEntity>(requestedRoles.Count);
|
||||
foreach (var roleName in requestedRoles)
|
||||
{
|
||||
var role = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
|
||||
if (role is not null)
|
||||
{
|
||||
resolved.Add(role);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string UpdateUserMetadata(string metadataJson, IReadOnlyList<string> roles)
|
||||
{
|
||||
var metadata = ParseMetadata(metadataJson);
|
||||
metadata["roles"] = roles;
|
||||
return JsonSerializer.Serialize(metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeRoles(IReadOnlyList<string>? roles)
|
||||
{
|
||||
if (roles is null || roles.Count == 0)
|
||||
|
||||
Reference in New Issue
Block a user