Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions docs/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<AuthorizeView Roles="Tenant.Roles.Assign,Tenant.Settings.Manage" Context="delegatedAdmin">
<MudNavGroup Title="Administration" Icon="@Icons.Material.Filled.AdminPanelSettings" Expanded="false">
<MudNavGroup Title="Accounts &amp; Orgs" Icon="@Icons.Material.Filled.AccountTree" Expanded="false">
<AuthorizeView Roles="Tenant.Users.Manage,Tenant.Roles.Assign" Context="teamAdministrator">
<MudNavLink Href="/admin/users" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Group">Team</MudNavLink>
</AuthorizeView>
<MudNavLink Href="/admin/organizations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Apartment">Organizations</MudNavLink>
</MudNavGroup>
<AuthorizeView Roles="Tenant.Roles.Assign" Context="roleAdministrator">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@using System.Net.Http.Json
@using HelpDesk.NewWeb.Components.Pages.Admin.User
@inject IHttpClientFactory HttpClientFactory
@inject IDialogService DialogService

<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2">Organization roles</MudText>
Expand All @@ -13,26 +15,25 @@ else if (loading)
}
else
{
<MudStack Spacing="0" Class="mt-2">
@foreach (var role in roles)
<MudStack Row="true" Wrap="Wrap.Wrap" Spacing="1" Class="mt-2">
@foreach (var roleKey in selectedRoleKeys.Take(3))
{
<MudCheckBox T="bool" Value="@selectedRoleKeys.Contains(role.Key)" ValueChanged="enabled => SetRole(role.Key, enabled)" Label="@role.Name" />
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@RoleLabel(roleKey)</MudChip>
}
@if (selectedRoleKeys.Count == 0) { <MudText Typo="Typo.caption" Color="Color.Secondary">No delegable roles assigned</MudText> }
@if (selectedRoleKeys.Count > 3) { <MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">+@(selectedRoleKeys.Count - 3) more</MudChip> }
</MudStack>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Disabled="@(!dirty || saving)" OnClick="SaveAsync" Class="mt-2">@(saving ? "Saving…" : "Save organization roles")</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="OpenEditorAsync" Class="mt-2">Edit roles</MudButton>
}

@code {
[Parameter, EditorRequired] public string OrganizationId { get; set; } = string.Empty;
[Parameter, EditorRequired] public string UserId { get; set; } = string.Empty;
private HttpClient Api => HttpClientFactory.CreateClient("HelpdeskApi");
private readonly List<RoleOption> roles = [];
private HashSet<string> originalRoleKeys = new(StringComparer.OrdinalIgnoreCase);
private HashSet<string> 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()
Expand All @@ -45,30 +46,30 @@ else
var membership = await Api.GetFromJsonAsync<Membership>($"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<string>(originalRoleKeys, StringComparer.OrdinalIgnoreCase);
selectedRoleKeys = membership?.RoleKeys.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new(StringComparer.OrdinalIgnoreCase);
}
catch (HttpRequestException)
{
error = "Organization roles could not be loaded for this account.";
}
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<TenantRoleAssignmentDialog>("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<string> RoleKeys);
private sealed record ReplaceMembership(IReadOnlyList<string> RoleKeys);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

<PageTitle>Roles & Permissions</PageTitle>

<MudPaper Class="pa-4">
<MudPaper Class="helpdesk-content-surface pa-4">
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
<MudStack Spacing="0">
<MudText Typo="Typo.h5">Roles & Permissions</MudText>
<MudText Typo="Typo.h4" HtmlTag="h1" Class="helpdesk-page-title">Roles & Permissions</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Permissions apply only in the organization shown on each assignment.</MudText>
</MudStack>
<MudSpacer />
Expand Down Expand Up @@ -63,7 +63,8 @@
}
else
{
<MudTable Items="roles" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<MudTextField T="string" @bind-Value="searchText" Label="Search roles" Immediate="true" Margin="Margin.Dense" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Class="helpdesk-list-search mb-3" />
<MudTable Items="FilteredRoles" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm" Class="role-management-table">
<HeaderContent>
<MudTh>Role</MudTh>
<MudTh>Scope / owner</MudTh>
Expand All @@ -73,12 +74,14 @@
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Role">
<MudStack Spacing="0">
<MudText>@context.Name</MudText>
<MudStack Spacing="0" Class="role-row-heading">
<MudText Typo="Typo.subtitle2">@context.Name</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@context.Key</MudText>
@if (context.IsProtected)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">Built-in protected</MudChip>
<MudTooltip Text="Built-in roles are protected and cannot be edited or deleted.">
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Outlined" Icon="@Icons.Material.Filled.Lock" Class="role-protected-status">Built-in</MudChip>
</MudTooltip>
}
</MudStack>
</MudTd>
Expand Down Expand Up @@ -130,6 +133,12 @@
private string? draftOwnerOrganizationId;
private HashSet<string> draftPermissions = new(StringComparer.OrdinalIgnoreCase);
private bool isInstanceAdministrator;
private string searchText = string.Empty;
private IEnumerable<RoleDefinition> 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()
{
Expand Down Expand Up @@ -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
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading