diff --git a/docs/rbac.md b/docs/rbac.md index 7ca00b9e..dd662770 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -9,7 +9,7 @@ sent by a browser is not trusted as an authorization decision. | Role | Scope | Access | | --- | --- | --- | | Instance Administrator | Instance | Full instance administration, including roles, tenant administration, and local accounts. It remains compatible with the `HelpdeskAdmin` role. | -| Tenant Administrator | Each assigned tenant | Tenant user, role-assignment, and settings permissions for that tenant. It has no database, identity-provider, or instance-administrator access. | +| Tenant Administrator | Each assigned tenant | Tenant user, role-assignment, settings, customer/contact, and SLA-management permissions for that tenant. It has no database, identity-provider, or instance-administrator access. | | Technician | Each assigned tenant | Incident, request and change read/write; change approval; self-service. Deletion is a separate permission. | | Incident / Request / Change Reader | Each assigned tenant | Read all records of the selected module in that tenant, including staff conversation history; no mutation. | | Incident / Request / Change Writer | Each assigned tenant | Read, create and update that module in the tenant. Deletion and change approval remain separate. | @@ -29,11 +29,13 @@ cannot delegate tenant-management, data-management, or instance permissions. ## Tenant membership delegation -The **Tenant members** navigation item appears only when the API resolves at -least one tenant where the signed-in principal has `Tenant.Roles.Assign`. The -page lists local accounts in those tenants, can add or remove the -operational built-in assignments or a custom role owned by that tenant, and -can invite a new local account directly into the selected tenant. The invitation +The tenant Team workflow is available at **Administration → Accounts & Orgs → +Team** (`/admin/users`) when the API resolves at least one tenant with +`Tenant.Users.Manage` or `Tenant.Roles.Assign`. It lists only members of the +selected authorized tenant. Role editing requires `Tenant.Roles.Assign`; local +invitations require both `Tenant.Users.Manage` and `Tenant.Roles.Assign` for +the same tenant. Compact member rows show only delegable assignments, and the +reusable editor keeps an independent draft until Save. The invitation response contains a one-time activation token; the administrator must share it through an approved secure channel. @@ -49,9 +51,9 @@ account actions. The API remains authoritative for the page and all direct requests: -- `GET /api/v1/tenant-admin/organizations` returns only tenants where the - caller can assign tenant roles (or all enabled tenants to an instance - administrator). +- `GET /api/v1/tenant-admin/organizations?permission=…` accepts only supported + tenant-management permissions and returns only enabled tenants for that + exact permission (or all enabled tenants to an instance administrator). - Tenant member and membership routes require the same tenant-scoped permission and reject users outside that tenant and instance administrators. - The membership route accepts operational built-ins and custom tenant roles only @@ -116,6 +118,17 @@ instance administration. Updates are recorded with actor and organization. Request-task approvals use the existing signed, designated-recipient capability. A Request Writer or Request Executor cannot approve on behalf of that recipient. +## Additional tenant administration capabilities + +`Tenant.Customers.Manage` is reserved for tenant-scoped customer/contact +workflows and `Tenant.Sla.Manage` is reserved for tenant SLA configuration, +calendars, policies, and reporting subscriptions. They are built into Tenant +Administrator and reconciled by the protected-role seeder on startup and +role-definition reads, so existing Tenant Administrator assignments gain the +capability without manual database edits or reassignment. They are intentionally +absent from the tenant-administrator delegation ceiling: a tenant administrator +can exercise these capabilities but cannot manufacture them in a custom role. + Incident and request bulk state/assignment changes require Write in every target organization. Every target and assignee is checked before any record changes; a batch containing a missing or unauthorized ticket changes nothing. The same diff --git a/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor b/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor index c61ef0d8..bbcb7722 100644 --- a/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor +++ b/src/HelpDesk.NewWeb/Components/Layout/TenantAdministrationNavigation.razor @@ -3,6 +3,9 @@ + + Team + Organizations diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor index e2b7dea4..21704ffc 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Customer/CustomerRoleAccessEditor.razor @@ -1,5 +1,7 @@ @using System.Net.Http.Json +@using HelpDesk.NewWeb.Components.Pages.Admin.User @inject IHttpClientFactory HttpClientFactory +@inject IDialogService DialogService Organization roles @@ -13,13 +15,15 @@ else if (loading) } else { - - @foreach (var role in roles) + + @foreach (var roleKey in selectedRoleKeys.Take(3)) { - + @RoleLabel(roleKey) } + @if (selectedRoleKeys.Count == 0) { No delegable roles assigned } + @if (selectedRoleKeys.Count > 3) { +@(selectedRoleKeys.Count - 3) more } - @(saving ? "Saving…" : "Save organization roles") + Edit roles } @code { @@ -27,12 +31,9 @@ else [Parameter, EditorRequired] public string UserId { get; set; } = string.Empty; private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); private readonly List roles = []; - private HashSet originalRoleKeys = new(StringComparer.OrdinalIgnoreCase); private HashSet selectedRoleKeys = new(StringComparer.OrdinalIgnoreCase); private bool loading = true; - private bool saving; private string? error; - private bool dirty => !originalRoleKeys.SetEquals(selectedRoleKeys); protected override Task OnInitializedAsync() => LoadAsync(); private async Task LoadAsync() @@ -45,8 +46,7 @@ else var membership = await Api.GetFromJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(UserId)}/assignments"); roles.Clear(); roles.AddRange(roleResponse); - originalRoleKeys = membership?.RoleKeys.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase); - selectedRoleKeys = new HashSet(originalRoleKeys, StringComparer.OrdinalIgnoreCase); + selectedRoleKeys = membership?.RoleKeys.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase); } catch (HttpRequestException) { @@ -54,21 +54,22 @@ else } finally { loading = false; } } - private void SetRole(string key, bool enabled) { if (enabled) selectedRoleKeys.Add(key); else selectedRoleKeys.Remove(key); } - private async Task SaveAsync() + private async Task OpenEditorAsync() { - saving = true; - error = null; - try + var dialog = await DialogService.ShowAsync("Edit linked account roles", new DialogParameters + { + ["OrganizationId"] = OrganizationId, ["OrganizationName"] = OrganizationId, + ["MemberId"] = UserId, ["MemberName"] = "Linked account", ["MemberEmail"] = string.Empty, + ["Roles"] = roles.Select(role => new TenantRoleAssignmentDialog.RoleOption(role.Key, role.Name)).ToArray(), + ["AssignedRoleKeys"] = selectedRoleKeys.ToArray() + }, new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }); + var result = await dialog.Result; + if (!result.Canceled) { - using var response = await Api.PutAsJsonAsync($"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(UserId)}/assignments", new ReplaceMembership(selectedRoleKeys.Order(StringComparer.OrdinalIgnoreCase).ToArray())); - if (!response.IsSuccessStatusCode) { error = await response.Content.ReadAsStringAsync(); return; } await LoadAsync(); } - catch (HttpRequestException) { error = "Organization roles could not be saved."; } - finally { saving = false; } } + private string RoleLabel(string key) => roles.FirstOrDefault(role => string.Equals(role.Key, key, StringComparison.OrdinalIgnoreCase))?.Name ?? key; private sealed record RoleOption(string Key, string Name); private sealed record Membership(string UserId, string OrganizationId, IReadOnlyList RoleKeys); - private sealed record ReplaceMembership(IReadOnlyList RoleKeys); } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor index 79dc6453..0214c65f 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor @@ -9,10 +9,10 @@ Roles & Permissions - + - Roles & Permissions + Roles & Permissions Permissions apply only in the organization shown on each assignment. @@ -63,7 +63,8 @@ } else { - + + Role Scope / owner @@ -73,12 +74,14 @@ - - @context.Name + + @context.Name @context.Key @if (context.IsProtected) { - Built-in protected + + Built-in + } @@ -130,6 +133,12 @@ private string? draftOwnerOrganizationId; private HashSet draftPermissions = new(StringComparer.OrdinalIgnoreCase); private bool isInstanceAdministrator; + private string searchText = string.Empty; + private IEnumerable FilteredRoles => string.IsNullOrWhiteSpace(searchText) + ? roles + : roles.Where(role => role.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + role.Key.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(role.OwnerOrganizationId) && OrganizationLabel(role.OwnerOrganizationId).Contains(searchText, StringComparison.OrdinalIgnoreCase))); protected override async Task OnInitializedAsync() { @@ -265,6 +274,7 @@ HelpdeskPermissions.RequestRead => "Read requests", HelpdeskPermissions.RequestWrite => "Write requests", HelpdeskPermissions.RequestDelete => "Delete requests", HelpdeskPermissions.RequestExecute => "Execute requests", HelpdeskPermissions.ChangeRead => "Read changes", HelpdeskPermissions.ChangeWrite => "Write changes", HelpdeskPermissions.ChangeDelete => "Delete changes", HelpdeskPermissions.ChangeApprove => "Approve changes", HelpdeskPermissions.TenantUsersManage => "Manage tenant users", HelpdeskPermissions.TenantRolesAssign => "Assign tenant roles", HelpdeskPermissions.TenantSettingsManage => "Manage tenant settings", + HelpdeskPermissions.TenantCustomersManage => "Manage tenant customers", HelpdeskPermissions.TenantSlaManage => "Manage tenant SLA", HelpdeskPermissions.SelfServiceUser => "Self-service access", _ => permission }; diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor.css b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor.css new file mode 100644 index 00000000..f68dca0e --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/Role/RoleManagement.razor.css @@ -0,0 +1,15 @@ +.role-row-heading { + gap: 0.125rem; +} + +.role-protected-status { + align-self: flex-start; + margin-top: 0.25rem; + cursor: default; +} + +.role-management-table :deep(.mud-table-cell) { + padding-top: 0.875rem; + padding-bottom: 0.875rem; + vertical-align: top; +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor index d5115009..7efb3d78 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantAdministration.razor @@ -1,13 +1,20 @@ @page "/tenant-administration" -@attribute [Authorize(Roles = "Tenant.Roles.Assign,HelpdeskAdmin")] +@attribute [Authorize(Roles = "Tenant.Users.Manage,Tenant.Roles.Assign,HelpdeskAdmin")] @inject IHttpClientFactory HttpClientFactory @inject NavigationManager NavigationManager +@inject IDialogService DialogService -Tenant members +@if (!IsCanonical) +{ + Tenant members +}
- Tenant members + @if (!IsCanonical) + { + Tenant members + } Manage delegable tenant roles for local accounts in a tenant you administer. Account credentials and instance-administrator access remain instance-administrator actions. @@ -32,26 +39,24 @@ } - Invite a local account - The account is created only in the selected tenant with self-service access. Share its one-time activation token through an approved secure channel. - - - - - - - - - - @(inviting ? "Creating" : "Create") - - - - @if (invitation is not null) + @if (canInvite) { - Copy this activation token now. It is shown only once. - - + + + The account is created only in the selected tenant with self-service access. Share its one-time activation token through an approved secure channel. + + + + @(inviting ? "Creating" : "Create") + + @if (invitation is not null) + { + Copy this activation token now. It is shown only once. + + + } + + } @if (loading) @@ -60,7 +65,8 @@ } else { - + + Name Email @@ -71,28 +77,33 @@ @context.Name @context.Email - - @foreach (var role in availableRoles) - { - - } - + @if (context.RoleKeys.Count == 0) + { + No delegable roles assigned + } + else + { + + @foreach (var roleKey in context.RoleKeys.Take(3)) + { + @RoleLabel(roleKey) + } + @if (context.RoleKeys.Count > 3) + { + +@(context.RoleKeys.Count - 3) more + } + + } - - @(savingUserId == context.UserId ? "Saving" : "Save") - + @if (canAssignRoles) + { + Edit roles + } - No local accounts belong to this tenant. + No members match this tenant and search. } @@ -107,10 +118,8 @@ private List organizations = new(); private List members = new(); private List availableRoles = new(); - private readonly Dictionary> roleAssignments = new(StringComparer.OrdinalIgnoreCase); private readonly TenantOrganizationSelection organizationSelection = new(); private string? selectedOrganizationId => organizationSelection.SelectedOrganizationId; - private string? savingUserId; private string? error; private bool loading; private bool inviting; @@ -120,17 +129,37 @@ private string inviteName = string.Empty; private string inviteEmail = string.Empty; private TenantLocalAccountInvitation? invitation; + private string memberSearch = string.Empty; + private bool canAssignRoles; + private HashSet inviteOrganizationIds = new(StringComparer.OrdinalIgnoreCase); + private bool canInvite => !string.IsNullOrWhiteSpace(selectedOrganizationId) && inviteOrganizationIds.Contains(selectedOrganizationId); + private IEnumerable FilteredMembers => string.IsNullOrWhiteSpace(memberSearch) + ? members + : members.Where(member => member.Name.Contains(memberSearch, StringComparison.OrdinalIgnoreCase) || member.Email.Contains(memberSearch, StringComparison.OrdinalIgnoreCase)); protected override async Task OnInitializedAsync() { - var response = await HelpdeskApi.GetAsync("api/v1/tenant-admin/organizations"); - if (!response.IsSuccessStatusCode) + var rolesResponse = await HelpdeskApi.GetAsync("api/v1/tenant-admin/organizations"); + var usersResponse = await HelpdeskApi.GetAsync("api/v1/tenant-admin/organizations?permission=Tenant.Users.Manage"); + if (!rolesResponse.IsSuccessStatusCode && !usersResponse.IsSuccessStatusCode) { error = "Tenant memberships could not be loaded. Refresh the page and try again."; return; } - organizations = await response.Content.ReadFromJsonAsync>() ?? new(); + var roleOrganizations = rolesResponse.IsSuccessStatusCode + ? await rolesResponse.Content.ReadFromJsonAsync>() ?? new() + : new(); + var userOrganizations = usersResponse.IsSuccessStatusCode + ? await usersResponse.Content.ReadFromJsonAsync>() ?? new() + : new(); + organizations = roleOrganizations.Concat(userOrganizations) + .DistinctBy(organization => organization.Id, StringComparer.OrdinalIgnoreCase) + .OrderBy(organization => organization.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + inviteOrganizationIds = roleOrganizations.Select(organization => organization.Id) + .Intersect(userOrganizations.Select(organization => organization.Id), StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); organizationsLoaded = true; await ApplyOrganizationParameterAsync(); } @@ -155,7 +184,7 @@ { members = new(); availableRoles = new(); - roleAssignments.Clear(); + canAssignRoles = false; error = "The requested organization is unavailable or you do not have permission to manage its members."; return; } @@ -224,13 +253,6 @@ error = null; try { - var rolesResponse = await HelpdeskApi.GetAsync(organizationSelection.Path("users/roles")); - if (!rolesResponse.IsSuccessStatusCode) - { - error = "Delegable tenant roles could not be loaded. Refresh the page and try again."; - return; - } - var response = await HelpdeskApi.GetAsync(organizationSelection.Path("users/")); if (!response.IsSuccessStatusCode) { @@ -238,14 +260,16 @@ return; } - availableRoles = await rolesResponse.Content.ReadFromJsonAsync>() ?? new(); members = await response.Content.ReadFromJsonAsync>() ?? new(); - roleAssignments.Clear(); - foreach (var member in members) + var rolesResponse = await HelpdeskApi.GetAsync(organizationSelection.Path("users/roles")); + canAssignRoles = rolesResponse.IsSuccessStatusCode; + if (canAssignRoles) + { + availableRoles = await rolesResponse.Content.ReadFromJsonAsync>() ?? new(); + } + else { - roleAssignments[member.UserId] = member.RoleKeys - .Where(roleKey => availableRoles.Any(role => string.Equals(role.Key, roleKey, StringComparison.OrdinalIgnoreCase))) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + availableRoles = new(); } } finally @@ -254,71 +278,32 @@ } } - private bool HasRole(TenantMember member, string roleKey) => - roleAssignments.TryGetValue(member.UserId, out var selectedRoles) && selectedRoles.Contains(roleKey); - - private bool IsChanged(TenantMember member) => - !CurrentRoleKeys(member).SetEquals(member.RoleKeys - .Where(roleKey => availableRoles.Any(role => string.Equals(role.Key, roleKey, StringComparison.OrdinalIgnoreCase)))); - - private void SetRole(TenantMember member, string roleKey, bool enabled) - { - var selectedRoles = CurrentRoleKeys(member); - if (enabled) - { - selectedRoles.Add(roleKey); - } - else - { - selectedRoles.Remove(roleKey); - } - } - - private async Task SaveMembership(TenantMember member) + private async Task OpenRoleDialogAsync(TenantMember member) { - if (string.IsNullOrWhiteSpace(selectedOrganizationId)) + var organization = organizations.FirstOrDefault(candidate => string.Equals(candidate.Id, selectedOrganizationId, StringComparison.OrdinalIgnoreCase)); + if (organization is null || !canAssignRoles) { return; } - - savingUserId = member.UserId; - error = null; - try + var dialog = await DialogService.ShowAsync("Edit member roles", new DialogParameters { - var response = await HelpdeskApi.PutAsJsonAsync( - $"{organizationSelection.Path("users")}/{Uri.EscapeDataString(member.UserId)}/assignments", - new ReplaceTenantMembershipRequest(CurrentRoleKeys(member) - .OrderBy(roleKey => roleKey, StringComparer.OrdinalIgnoreCase) - .ToArray())); - if (!response.IsSuccessStatusCode) - { - error = "Tenant membership could not be saved. Confirm that you still have access to this tenant and try again."; - return; - } - - await LoadMembers(); - } - finally + ["OrganizationId"] = organization.Id, ["OrganizationName"] = organization.Name, + ["MemberId"] = member.UserId, ["MemberName"] = member.Name, ["MemberEmail"] = member.Email, + ["Roles"] = availableRoles.Select(role => new TenantRoleAssignmentDialog.RoleOption(role.Key, role.Name)).ToArray(), + ["AssignedRoleKeys"] = member.RoleKeys.ToArray() + }, new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }); + var result = await dialog.Result; + if (!result.Canceled) { - savingUserId = null; + await LoadMembers(); } } - private HashSet CurrentRoleKeys(TenantMember member) - { - if (!roleAssignments.TryGetValue(member.UserId, out var selectedRoles)) - { - selectedRoles = new HashSet(StringComparer.OrdinalIgnoreCase); - roleAssignments[member.UserId] = selectedRoles; - } - - return selectedRoles; - } + private string RoleLabel(string key) => availableRoles.FirstOrDefault(role => string.Equals(role.Key, key, StringComparison.OrdinalIgnoreCase))?.Name ?? key; private sealed record TenantOrganization(string Id, string Name); private sealed record TenantMember(string UserId, string Name, string Email, IReadOnlyList RoleKeys); private sealed record TenantDelegableRole(string Key, string Name); - private sealed record ReplaceTenantMembershipRequest(IReadOnlyList RoleKeys); private sealed record CreateTenantLocalAccountRequest(string DisplayName, string Email); private sealed record TenantLocalAccountInvitation(string UserId, string Email, string ActivationToken); } diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantRoleAssignmentDialog.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantRoleAssignmentDialog.razor new file mode 100644 index 00000000..eba04a3b --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantRoleAssignmentDialog.razor @@ -0,0 +1,118 @@ +@inject IHttpClientFactory HttpClientFactory + + + + + Edit roles for @MemberName + @MemberEmail · @OrganizationName + + + + @if (!string.IsNullOrWhiteSpace(error)) + { + @error + } + + @selectedRoleKeys.Count role@(selectedRoleKeys.Count == 1 ? string.Empty : "s") selected + + @foreach (var role in FilteredRoles) + { + + + + } + + @if (FilteredRoles.Count == 0) + { + No assignable roles match your search. + } + + + Cancel + @(saving ? "Saving…" : "Save roles") + + + +@code { + [CascadingParameter] private IMudDialogInstance Dialog { get; set; } = default!; + [Parameter, EditorRequired] public string OrganizationId { get; set; } = string.Empty; + [Parameter, EditorRequired] public string OrganizationName { get; set; } = string.Empty; + [Parameter, EditorRequired] public string MemberId { get; set; } = string.Empty; + [Parameter, EditorRequired] public string MemberName { get; set; } = string.Empty; + [Parameter, EditorRequired] public string MemberEmail { get; set; } = string.Empty; + [Parameter, EditorRequired] public IReadOnlyList Roles { get; set; } = []; + [Parameter, EditorRequired] public IReadOnlyList AssignedRoleKeys { get; set; } = []; + + private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi"); + private HashSet originalRoleKeys = new(StringComparer.OrdinalIgnoreCase); + private HashSet selectedRoleKeys = new(StringComparer.OrdinalIgnoreCase); + private string search = string.Empty; + private bool saving; + private string? error; + private IReadOnlyList FilteredRoles => Roles + .Where(role => string.IsNullOrWhiteSpace(search) || + role.Name.Contains(search, StringComparison.OrdinalIgnoreCase) || + role.Key.Contains(search, StringComparison.OrdinalIgnoreCase)) + .OrderBy(role => role.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + private bool IsChanged => !originalRoleKeys.SetEquals(selectedRoleKeys); + + protected override void OnInitialized() + { + originalRoleKeys = AssignedRoleKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); + selectedRoleKeys = new HashSet(originalRoleKeys, StringComparer.OrdinalIgnoreCase); + } + + private void SetRole(string key, bool enabled) + { + if (enabled) + { + selectedRoleKeys.Add(key); + } + else + { + selectedRoleKeys.Remove(key); + } + } + + private async Task SaveAsync() + { + saving = true; + error = null; + try + { + var path = $"api/v1/tenant-admin/organizations/{Uri.EscapeDataString(OrganizationId)}/users/{Uri.EscapeDataString(MemberId)}/assignments"; + using var response = await Api.PutAsJsonAsync(path, new ReplaceTenantMembershipRequest(selectedRoleKeys.Order(StringComparer.OrdinalIgnoreCase).ToArray())); + if (!response.IsSuccessStatusCode) + { + error = "Roles could not be saved. Your tenant access or available role catalog may have changed."; + return; + } + + Dialog.Close(DialogResult.Ok(true)); + } + catch (HttpRequestException) + { + error = "Roles could not be saved. Check your connection and try again."; + } + finally + { + saving = false; + } + } + + private void Cancel() => Dialog.Cancel(); + + public sealed record RoleOption(string Key, string Name); + private sealed record ReplaceTenantMembershipRequest(IReadOnlyList RoleKeys); +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantRoleAssignmentDialog.razor.css b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantRoleAssignmentDialog.razor.css new file mode 100644 index 00000000..3ff98157 --- /dev/null +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/TenantRoleAssignmentDialog.razor.css @@ -0,0 +1,4 @@ +.tenant-role-dialog-list { + max-height: min(48vh, 32rem); + overflow-y: auto; +} diff --git a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/UserManagement.razor b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/UserManagement.razor index f76c04e3..68dae452 100644 --- a/src/HelpDesk.NewWeb/Components/Pages/Admin/User/UserManagement.razor +++ b/src/HelpDesk.NewWeb/Components/Pages/Admin/User/UserManagement.razor @@ -1,5 +1,5 @@ @page "/admin/users" -@attribute [Authorize(Roles = "HelpdeskAdmin")] +@attribute [Authorize(Roles = "Tenant.Users.Manage,Tenant.Roles.Assign,HelpdeskAdmin")] @inject IHttpClientFactory HttpClientFactory @inject IDialogService DialogService @inject HelpDesk.NewWeb.Models.WebAuthenticationMode AuthenticationMode @@ -8,6 +8,8 @@ Team + +
@@ -67,6 +69,17 @@
+
+ +
+ + Team + Manage membership and application roles for an authorized tenant. Shared account credentials and lifecycle remain instance-administrator actions. + + +
+
+
@code { [SupplyParameterFromQuery(Name = "search")] diff --git a/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs b/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs index 82c012a9..17c2ab22 100644 --- a/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs +++ b/src/Helpdesk.API/Endpoints/Users/TenantAdministrationEndpoints.cs @@ -28,9 +28,14 @@ public static void MapTenantAdministrationEndpoints(this IEndpointRouteBuilder a CancellationToken cancellationToken) => { var access = await accessService.ResolveAsync(context.User, cancellationToken); - var requiredPermission = string.Equals(permission, HelpdeskPermissions.TenantSettingsManage, StringComparison.OrdinalIgnoreCase) - ? HelpdeskPermissions.TenantSettingsManage - : HelpdeskPermissions.TenantRolesAssign; + var requiredPermission = ResolveOrganizationLookupPermission(permission); + if (requiredPermission is null) + { + return Results.ValidationProblem(new Dictionary + { + ["permission"] = ["Choose a supported tenant administration permission."] + }); + } var organizationIds = access.IsHelpdeskAdmin ? null : access.OrganizationIdsFor(requiredPermission); @@ -195,7 +200,7 @@ await users.FindByEmailAsync(email) is not null) UserManager users, CancellationToken cancellationToken) => { - if (!await CanManageAsync(context, accessService, organizationId, cancellationToken)) return Results.Forbid(); + if (!await CanViewMembersAsync(context, accessService, organizationId, cancellationToken)) return Results.Forbid(); var members = await db.Users.AsNoTracking() .Where(user => user.OrganizationId == organizationId || db.ScopedRoleAssignments.Any(assignment => @@ -383,6 +388,27 @@ private static async Task CanManageAsync(HttpContext context, ICurrentUser return access.IsHelpdeskAdmin || access.HasPermission(HelpdeskPermissions.TenantRolesAssign, organizationId); } + private static async Task CanViewMembersAsync(HttpContext context, ICurrentUserAccessService accessService, string organizationId, CancellationToken cancellationToken) + { + var access = await accessService.ResolveAsync(context.User, cancellationToken); + return access.IsHelpdeskAdmin || + access.HasPermission(HelpdeskPermissions.TenantUsersManage, organizationId) || + access.HasPermission(HelpdeskPermissions.TenantRolesAssign, organizationId); + } + + private static string? ResolveOrganizationLookupPermission(string? permission) => + string.IsNullOrWhiteSpace(permission) + ? HelpdeskPermissions.TenantRolesAssign + : permission.Trim() switch + { + HelpdeskPermissions.TenantSettingsManage => HelpdeskPermissions.TenantSettingsManage, + HelpdeskPermissions.TenantUsersManage => HelpdeskPermissions.TenantUsersManage, + HelpdeskPermissions.TenantRolesAssign => HelpdeskPermissions.TenantRolesAssign, + HelpdeskPermissions.TenantCustomersManage => HelpdeskPermissions.TenantCustomersManage, + HelpdeskPermissions.TenantSlaManage => HelpdeskPermissions.TenantSlaManage, + _ => null + }; + private static async Task CanInviteAsync(HttpContext context, ICurrentUserAccessService accessService, string organizationId, CancellationToken cancellationToken) { var access = await accessService.ResolveAsync(context.User, cancellationToken); diff --git a/src/Helpdesk.Shared/Auth/HelpdeskPermissions.cs b/src/Helpdesk.Shared/Auth/HelpdeskPermissions.cs index 1054c867..28f76502 100644 --- a/src/Helpdesk.Shared/Auth/HelpdeskPermissions.cs +++ b/src/Helpdesk.Shared/Auth/HelpdeskPermissions.cs @@ -25,6 +25,8 @@ public static class HelpdeskPermissions public const string TenantUsersManage = "Tenant.Users.Manage"; public const string TenantRolesAssign = "Tenant.Roles.Assign"; public const string TenantSettingsManage = "Tenant.Settings.Manage"; + public const string TenantCustomersManage = "Tenant.Customers.Manage"; + public const string TenantSlaManage = "Tenant.Sla.Manage"; public static readonly string[] AssignablePermissions = [ @@ -49,7 +51,9 @@ public static class HelpdeskPermissions DataManagementAdmin, TenantUsersManage, TenantRolesAssign, - TenantSettingsManage + TenantSettingsManage, + TenantCustomersManage, + TenantSlaManage ]; public static readonly string[] UserBundle = diff --git a/src/Helpdesk.Shared/Auth/RoleDefinitionCatalog.cs b/src/Helpdesk.Shared/Auth/RoleDefinitionCatalog.cs index eaf49af1..f4f609bf 100644 --- a/src/Helpdesk.Shared/Auth/RoleDefinitionCatalog.cs +++ b/src/Helpdesk.Shared/Auth/RoleDefinitionCatalog.cs @@ -52,7 +52,9 @@ public static class RoleDefinitionCatalog [ HelpdeskPermissions.TenantUsersManage, HelpdeskPermissions.TenantRolesAssign, - HelpdeskPermissions.TenantSettingsManage + HelpdeskPermissions.TenantSettingsManage, + HelpdeskPermissions.TenantCustomersManage, + HelpdeskPermissions.TenantSlaManage ]), new( ScopedRoleCatalog.Technician, diff --git a/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs b/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs index 7ed85769..4f4f54cf 100644 --- a/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs +++ b/tests/Helpdesk.Tests/Infrastructure/CurrentUserAccessServiceTests.cs @@ -391,6 +391,37 @@ public async Task Protected_built_in_role_definitions_seed_idempotently() .SequenceEqual(HelpdeskPermissions.OperatorBundle.OrderBy(permission => permission))); } + [Fact] + public async Task Existing_tenant_administrator_definition_is_reconciled_with_scoped_customer_and_sla_permissions() + { + await using var db = CreateDb(); + db.Roles.Add(new Role + { + Id = "legacy-tenant-administrator", + Key = ScopedRoleCatalog.TenantAdministrator, + Name = "Tenant Administrator", + Scope = RoleScopeKind.Tenant, + IsBuiltIn = true, + IsProtected = true, + Permissions = + [ + new RolePermission { Permission = HelpdeskPermissions.TenantUsersManage }, + new RolePermission { Permission = HelpdeskPermissions.TenantRolesAssign }, + new RolePermission { Permission = HelpdeskPermissions.TenantSettingsManage } + ] + }); + await db.SaveChangesAsync(); + + await RoleDefinitionSeeder.EnsureBuiltInsAsync(db); + + var tenantAdministrator = await db.Roles.Include(role => role.Permissions) + .SingleAsync(role => role.Key == ScopedRoleCatalog.TenantAdministrator); + Assert.Contains(HelpdeskPermissions.TenantCustomersManage, tenantAdministrator.Permissions.Select(permission => permission.Permission)); + Assert.Contains(HelpdeskPermissions.TenantSlaManage, tenantAdministrator.Permissions.Select(permission => permission.Permission)); + Assert.DoesNotContain(HelpdeskPermissions.TenantCustomersManage, RoleDefinitionCatalog.TenantAdministratorPermissionCeiling); + Assert.DoesNotContain(HelpdeskPermissions.TenantSlaManage, RoleDefinitionCatalog.TenantAdministratorPermissionCeiling); + } + private static ClaimsPrincipal User(string email, string subject, params string[] groups) => User(email, subject, tenantId: null, groups); diff --git a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs index 65976f1d..fce0845a 100644 --- a/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs +++ b/tests/Helpdesk.Tests/NewWeb/PageAuthorizationConventionsTests.cs @@ -62,8 +62,11 @@ public void TenantMembershipPage_UsesApiResolvedTenantAccess() var navMenu = File.ReadAllText(Path.Combine(repoRoot, "src", "HelpDesk.NewWeb", "Components", "Layout", "NavMenu.razor")); Assert.Contains("@page \"/tenant-administration\"", page, StringComparison.Ordinal); - Assert.Contains("@attribute [Authorize(Roles = \"Tenant.Roles.Assign,HelpdeskAdmin\")]", page, StringComparison.Ordinal); + Assert.Contains("@attribute [Authorize(Roles = \"Tenant.Users.Manage,Tenant.Roles.Assign,HelpdeskAdmin\")]", page, StringComparison.Ordinal); Assert.Contains("api/v1/tenant-admin/organizations", page, StringComparison.Ordinal); + Assert.Contains("TenantRoleAssignmentDialog", page, StringComparison.Ordinal); + Assert.Contains("No delegable roles assigned", page, StringComparison.Ordinal); + Assert.Contains("Edit roles", page, StringComparison.Ordinal); Assert.Contains("OnParametersSetAsync", page, StringComparison.Ordinal); Assert.Contains("ApplyOrganizationParameterAsync", page, StringComparison.Ordinal); Assert.Contains("Roles=\"Tenant.Roles.Assign,Tenant.Settings.Manage\"", navigation, StringComparison.Ordinal); @@ -82,5 +85,13 @@ public void TenantMembershipPage_UsesApiResolvedTenantAccess() Assert.Contains("Members & access", organizationPage, StringComparison.Ordinal); Assert.Contains("OrganizationId=\"@OrganizationId\"", organizationPage, StringComparison.Ordinal); Assert.Contains("", navMenu, StringComparison.Ordinal); + + var teamPage = File.ReadAllText(Path.Combine(repoRoot, "src", "HelpDesk.NewWeb", "Components", "Pages", "Admin", "User", "UserManagement.razor")); + Assert.Contains("@attribute [Authorize(Roles = \"Tenant.Users.Manage,Tenant.Roles.Assign,HelpdeskAdmin\")]", teamPage, StringComparison.Ordinal); + Assert.Contains("", teamPage, StringComparison.Ordinal); + + var rolePage = File.ReadAllText(Path.Combine(repoRoot, "src", "HelpDesk.NewWeb", "Components", "Pages", "Admin", "Role", "RoleManagement.razor")); + Assert.Contains("role-protected-status", rolePage, StringComparison.Ordinal); + Assert.Contains("Variant=\"Variant.Outlined\"", rolePage, StringComparison.Ordinal); } }