Repair first-time identity and trust operator journeys

This commit is contained in:
master
2026-03-15 12:33:56 +02:00
parent 7bdfcd5055
commit 08390f0ca4
27 changed files with 5814 additions and 2425 deletions

View File

@@ -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,

View File

@@ -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)