Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
f0fb844
feat(studio): port 3.9 permission gating (batch 1)
sfmskywalker Oct 3, 2026
9591267
feat(studio): port 3.9 permission gating (batch 1)
sfmskywalker Oct 3, 2026
c19d967
feat(studio): port 3.9 permission gating (batch 1)
sfmskywalker Oct 3, 2026
c1ad870
feat(studio): port 3.9 permission gating (batch 2)
sfmskywalker Oct 3, 2026
7853a16
feat(studio): port 3.9 permission gating (batch 2)
sfmskywalker Oct 3, 2026
44559ed
feat(studio): port 3.9 permission gating (batch 2)
sfmskywalker Oct 3, 2026
44f8dab
feat(studio): port 3.9 permission gating (batch 2)
sfmskywalker Oct 3, 2026
f58753a
feat(studio): port 3.9 permission gating (batch 3)
sfmskywalker Oct 3, 2026
877ef52
feat(studio): port 3.9 permission gating (batch 3)
sfmskywalker Oct 3, 2026
04340c1
feat(studio): port 3.9 permission gating (batch 4)
sfmskywalker Oct 3, 2026
9f9e758
feat(studio): port 3.9 permission gating (batch 4)
sfmskywalker Oct 3, 2026
52b86f5
feat(studio): port 3.9 permission gating (batch 4)
sfmskywalker Oct 3, 2026
733752d
feat(studio): port 3.9 permission gating (batch 5)
sfmskywalker Oct 3, 2026
8d89268
feat(studio): port 3.9 permission gating (batch 5)
sfmskywalker Oct 3, 2026
7cd9eb7
feat(studio): port 3.9 permission gating (batch 5)
sfmskywalker Oct 3, 2026
211eaf0
feat(studio): port 3.9 permission gating (batch 5)
sfmskywalker Oct 3, 2026
98ebece
feat(studio): port 3.9 permission gating (batch 6)
sfmskywalker Oct 3, 2026
2c76b6b
feat(studio): port 3.9 permission gating (batch 6)
sfmskywalker Oct 3, 2026
65bfbc2
feat(studio): port 3.9 permission gating (batch 6)
sfmskywalker Oct 3, 2026
8edf860
feat(studio): port 3.9 permission gating (batch 6)
sfmskywalker Oct 3, 2026
720726e
feat(studio): port 3.9 permission gating (batch 6)
sfmskywalker Oct 3, 2026
5340c01
feat(studio): port 3.9 permission gating (batch 7)
sfmskywalker Oct 3, 2026
231a835
feat(studio): port 3.9 permission gating (batch 7)
sfmskywalker Oct 3, 2026
4bdc37d
feat(studio): port 3.9 permission gating (batch 7)
sfmskywalker Oct 3, 2026
178b595
feat(studio): port 3.9 permission gating (batch 7)
sfmskywalker Oct 3, 2026
c065135
feat(studio): port 3.9 permission gating (batch 7)
sfmskywalker Oct 3, 2026
881bbab
feat(studio): port 3.9 permission gating (batch 7)
sfmskywalker Oct 3, 2026
51d1ffb
feat(studio): port 3.9 permission gating (batch 8)
sfmskywalker Oct 3, 2026
f476adb
feat(studio): port 3.9 permission gating (batch 8)
sfmskywalker Oct 3, 2026
6692f7c
feat(studio): port 3.9 permission gating (batch 8)
sfmskywalker Oct 3, 2026
21250e9
feat(studio): port 3.9 permission gating (batch 8)
sfmskywalker Oct 3, 2026
a18dc73
feat(studio): port 3.9 permission gating (batch 9)
sfmskywalker Oct 3, 2026
234b66c
feat(studio): port 3.9 permission gating (batch 9)
sfmskywalker Oct 3, 2026
0c7c3e8
feat(studio): port Connections Edit.razor Refit rename
sfmskywalker Oct 3, 2026
7a06b09
refactor(studio): drop colliding IExternalAuthenticationConnectionsApi
sfmskywalker Oct 3, 2026
fc0468c
refactor(studio): drop colliding IExternalIdentityLinksApi
sfmskywalker Oct 3, 2026
ede098b
fix(studio): finish Refit rename and compile the permission port
cursoragent Oct 3, 2026
583d7c4
fix(studio): restore razor files corrupted by the GitHub upload
cursoragent Oct 3, 2026
575c323
test(studio): expect the shared access-denied copy on Users pages
cursoragent Oct 3, 2026
f95a897
merge main into cursor/forward-port-39-studio-identity-62d2
cursoragent Oct 3, 2026
f346a40
merge main into cursor/forward-port-39-studio-identity-62d2
cursoragent Oct 3, 2026
e1ee80d
fix(studio): restore IsInitialized default, catalog webhooks, invalid…
cursoragent Oct 3, 2026
46bf79d
port(studio): restore 3.9 identity gating, session, and source tests
cursoragent Oct 3, 2026
57ed28f
port(studio): finish remaining 3.9 permission and access-denied hunks
cursoragent Oct 3, 2026
8cea64a
fix(studio): PathBase logout, antiforgery, snapshot refresh, R1 tests
cursoragent Oct 3, 2026
82668d4
fix(studio): restore R1 test compile after session-gate and editor ports
cursoragent Oct 3, 2026
1d9f153
fix(studio): adapt OIDC and access-denied tests to main hosting APIs
cursoragent Oct 3, 2026
7b1c75c
fix(studio): use WebhooksPage alias in gating tests
cursoragent Oct 3, 2026
b302ee3
merge main into studio identity
cursoragent Oct 3, 2026
aab2422
fix(studio): break permission cache cycle and notify subscribers
cursoragent Oct 3, 2026
1572857
fix(studio): disambiguate ServiceCollectionExtensions in Security tests
cursoragent Oct 3, 2026
e07d2e3
fix(studio): scope permission refresh and make cache generation atomic
cursoragent Oct 3, 2026
a244d07
fix(studio): stop permission refresh livelock and keep admin editors
cursoragent Oct 3, 2026
c937a27
fix(studio): disable admin mutations while access is re-checked
cursoragent Oct 3, 2026
c67ff78
fix(studio): wait for queued access refresh in boundary tests
cursoragent Oct 3, 2026
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
20 changes: 20 additions & 0 deletions doc/migrations/authorization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,23 @@ One caveat: the composite indexes only cover rows whose `TenantId` is non-null (
| `ai:proposals:view` | *removed* — unused |
| `ai:proposals:approve` | *removed* — unused |
| `ai:proposals:apply` | *removed* — unused |

## Studio hosts without the Security module

Studio matches 3.9: a host that does not register `IPermissionService` (no Security module) uses `UserPermissions.Unknown`, so every permission check passes. That is an explicit choice for hosts that do not enforce authorization in the shell. The CustomElements host (`Elsa.Studio.Host.CustomElements`) runs in this Unknown mode: it registers Core, Shell, Workflows, Secrets and User Tasks, but not Security. Install the Security module to fail closed on `GET /identity/me/permissions`.

The Webhooks page is gated on `http/webhooks:view`. That resource is in the backend catalog so a `*` administrator receives it. Dashboard per-widget gating depends on the dashboard section permissions from #8572.

## Studio public API notes

These Studio-side contract changes ship with this release and need a rebuild of any extension that referenced the old names:

- Studio-local Refit interfaces were renamed so they do not collide with `Elsa.Api.Client`: `IExternalAuthenticationConnectionsApi` → `IExternalAuthenticationConnectionManagementApi`, and `IExternalIdentityLinksApi` → `IExternalIdentityLinkManagementApi`.
- `ExternalAuthenticationPermissions` values now use the `{resource}:{verb}` grammar (`external-authentication/connections:view`, and so on). Old Studio-only names such as `external-authentication:connections:read` are not granted by the catalog.
- `IFeatureService.IsInitialized` stays a default interface member (`=> false`). Third-party implementations and decorators do not have to add the member.
- Direct OIDC logout is POST-only: `GET /authentication/logout` does not sign the user out. The hosted form posts to `HostedAuthenticationPaths.LogoutFormAction(baseUri)`, which is `NavigationManager.ToAbsoluteUri("authentication/logout").AbsolutePath`, so a PathBase such as `/studio/` is preserved.
- `AddOpenIdConnectAuth` calls `AddControllersWithViews()`, not `AddControllers()`. The logout action uses `[ValidateAntiForgeryToken]`, and that filter is registered only with the MVC view features.
- `IPermissionSnapshotCache` is owned by `IdentityPermissionContext`. `Invalidate` increments a generation token, drops the snapshot, then raises `Changed` so permission-dependent UI re-fetches. A snapshot is stored only for the generation it was loaded under, and `GetAsync` returns only current-generation grants. An overtaken load is retried at most three times, then `Unavailable` is returned without being cached (fail closed). Environment switches raise `IPermissionRefreshSignal`. A silent JWT refresh raises it only when the refresh failed, or when the `sub` or `permissions` claim changed between the old and new tokens. The signal is a no-dependency scoped service, one per Blazor Server circuit, instead of taking the cache, which would cycle through the backend client.
- `DefaultEnvironmentService` now takes an optional `IEnumerable<IPermissionRefreshSignal>` instead of `IEnumerable<IPermissionSnapshotCache>`. The previous two-argument constructor that accepted the cache enumerable is gone.
- `JwtTokenProvider` gained an `IEnumerable<IPermissionRefreshSignal>` constructor parameter; the original four-argument constructor remains and passes an empty set.
- `ElsaIdentitySignOutService.LoginPath` is obsolete. Sign-out navigates with `NavigationManager.ToAbsoluteUri("login").PathAndQuery` so a PathBase is kept. If clearing the stored tokens fails, the service stays on the page rather than opening login with the token still present.
4 changes: 2 additions & 2 deletions doc/studio/AUTHENTICATION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,9 +746,9 @@ Options/
```
Client/
├── IExternalAuthenticationBrokerApi.cs # authorize / token / logout
├── IExternalAuthenticationConnectionsApi.cs
├── IExternalAuthenticationConnectionManagementApi.cs
├── IExternalAuthenticationOperationsApi.cs
├── IExternalIdentityLinksApi.cs
├── IExternalIdentityLinkManagementApi.cs
└── IIdentityRolesApi.cs # (ILoginMethodsApi lives alongside the broker API)

Models/
Expand Down
24 changes: 19 additions & 5 deletions doc/studio/DASHBOARD_ARCHITECTURE_BLUEPRINT.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ public record DashboardWidgetDescriptor(
Type ComponentType,
string? Title = null,
string? RequiredBackendCapability = null,
string? PayloadKind = null);
string? PayloadKind = null)
{
public IReadOnlyCollection<Permission> RequiredPermissions { get; init; } = [];
}
```

Descriptor fields:
Expand All @@ -122,6 +125,7 @@ Descriptor fields:
- `Title`: optional metadata.
- `RequiredBackendCapability`: metadata for the backend capability a widget expects. The current shell does not enforce this field.
- `PayloadKind`: metadata documenting which snapshot payload the widget consumes.
- `RequiredPermissions`: the widget is shown to users holding any of them, and to everyone when it declares none. See Authorization and Capability Awareness.

### Registry Pattern

Expand All @@ -138,12 +142,13 @@ The dashboard page merges both sources:
Widgets
.Concat(WidgetRegistry.List())
.DistinctBy(x => x.Id)
.Where(x => x.IsPermitted(Permissions))
.Where(x => x.Zone == zone && x.IsVisible(WidgetContext))
.OrderBy(x => x.Order)
.ThenBy(x => x.Id, StringComparer.Ordinal)
```

`IsVisible` currently requires only a non-null snapshot. Capability-specific empty or unavailable states belong inside each widget component.
`IsPermitted` checks the user's permissions against `RequiredPermissions`; `IsVisible` requires a non-null snapshot. Capability-specific empty or unavailable states belong inside each widget component.

### Zones

Expand Down Expand Up @@ -190,7 +195,7 @@ Remote-gated companion features:
- Structured logs dashboard: `Elsa.Diagnostics.StructuredLogs.Dashboard.ShellFeatures.StructuredLogsDashboard`
- Console logs dashboard: `Elsa.Diagnostics.ConsoleLogs.Dashboard.ShellFeatures.ConsoleLogsDashboard`

The dashboard page subscribes to `IFeatureService.Initialized` after first render so late widget registration can trigger a UI refresh.
The dashboard page subscribes to `IFeatureService.Initialized` before its first render so late widget registration can trigger a UI refresh, and a load of the data the new widgets need. Until `IFeatureService.IsInitialized` is true, a user with no permitted widget sees the loading state rather than the welcome panel, since their widgets may still be on their way.

## Data Architecture

Expand Down Expand Up @@ -232,7 +237,7 @@ Time range handling is centralized in `DashboardRangeMapper`:
- `GET /dashboard/recent-activity`
- `POST /dashboard/workflow-hotspots`

`DashboardService.LoadAsync` requests overview, needs attention, trends, recent activity, and hotspots concurrently with `Task.WhenAll`. It maps transport errors to `DashboardLoadResult`:
`DashboardService.LoadAsync` requests overview, needs attention, trends, recent activity, and hotspots concurrently with `Task.WhenAll`. The page uses it only for users who may read workflow instance data (`dashboard:view` or `workflows/instances:view`); for other users with a visible widget it requests the overview alone through `LoadOverviewAsync`. A user with no visible widget who may read the runtime status (`dashboard:view` or `workflows/runtime:view`) also gets the overview, so the header chip can render; otherwise it requests nothing. Both map transport errors to `DashboardLoadResult`:

- `404`: dashboard unavailable.
- `401` or `403`: unauthorized.
Expand Down Expand Up @@ -277,7 +282,16 @@ The shell uses MudBlazor for layout, chips, tables, charts, buttons, alerts, and

### Authorization and Capability Awareness

Studio does not enforce dashboard authorization client-side. Authorization failures come from the backend and are mapped by `DashboardService`.
Every signed-in user can open the dashboard: the page and its menu item declare no permission. The backend serves each overview section, and the workflow instance endpoints, to `dashboard:view` and to the view permission of the data (`workflows/instances`, `workflows/runtime`, `diagnostics/structured-logs`, `diagnostics/console-logs`). Studio mirrors that rule to tailor the page, never as an authorization decision:

- widgets declare the permissions of their data in `RequiredPermissions` and are hidden from users holding none of them;
- the page requests only the data its visible widgets need, from endpoints the user may call;
- a section the backend returns with `Capability` `Unauthorized` is left out rather than reported, and the runtime chip is shown only to users who may read the runtime status;
- a user with no visible widget gets a welcome panel with shortcuts to the pages they can open, or the "No pages are available for your role" notice; a user who may read the runtime status (`dashboard:view` or `workflows/runtime:view`) sees it above the shortcuts;
- the page waits for the features to report they are initialized before welcoming a user, but no longer than a few seconds, so a host that never initializes them settles on the welcome; widgets registered later still appear;
- when the overview loads but an instance endpoint refuses the user (unknown permissions, or an older backend), the overview stays and the widgets that need the refused part show nothing.

When the user's permissions are unknown, every widget shows and every endpoint is requested, as before; refusals from the backend are then mapped by `DashboardService`.

Remote feature gating controls whether companion widget features initialize. Per-widget `RequiredBackendCapability` is metadata today; widgets render their own capability chips or empty states based on snapshot data.

Expand Down
16 changes: 13 additions & 3 deletions specs/studio/007-operational-dashboard/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Widget `Id` values must be globally unique across all providers and should use a

- `Available`: render the widget component.
- `Unavailable`: render consistent host unavailable chrome with the descriptor title and optional provider-supplied reason.
- `Unauthorized`: render consistent host unauthorized chrome without leaking unavailable data details. The host must ignore `UnavailableReason` for unauthorized widgets even if a provider supplies one.
- `Unauthorized`: leave the widget or section out entirely, without unauthorized chrome and without leaking unavailable data details: the user is not meant to see it. The host must ignore `UnavailableReason` for unauthorized widgets even if a provider supplies one.

`DashboardWidgetRefreshMode` should define host behavior:

Expand Down Expand Up @@ -410,7 +410,7 @@ Behavior:

- If a diagnostics module is absent, its provider is not registered and the widget is not contributed.
- If the module is installed but its backend feature is unavailable, the provider may omit the widget or return it with `DashboardWidgetAvailability.Unavailable`.
- If unauthorized, the provider may omit the widget or return it with `DashboardWidgetAvailability.Unauthorized` so the host can show `No access`.
- If unauthorized, the provider may omit the widget or return it with `DashboardWidgetAvailability.Unauthorized` so the host leaves it out rather than showing `No access`.
- Do not display raw log lines.

### Workflow Hotspots Widget
Expand Down Expand Up @@ -553,6 +553,15 @@ public interface IDashboardApi
}
```

## Access

Decided in elsa-studio#1099, with the backend side in elsa-core#8561:

- Every signed-in user can open the dashboard. The route and its menu item require no permission.
- Each widget is gated by the permission of the data it shows, declared on its descriptor. A user sees a widget when they hold any of its permissions, and a widget the user may not see is hidden, not disabled. Built-in widgets accept `dashboard:view` or the view permission of their data: `workflows/instances` for workflow metrics, trends, activity, findings and hotspots, `diagnostics/structured-logs` and `diagnostics/console-logs` for the log summaries. The runtime status needs `dashboard:view` or `workflows/runtime:view`. The OpenTelemetry widget, which loads its figures from the OpenTelemetry API rather than the dashboard's, needs `diagnostics/opentelemetry:view`.
- The backend applies the same rule. The overview marks a section the caller may not read with `Capability = Unauthorized` and sends no data for it, and the host leaves such a section out. Metric cards and panels the caller may not read are dropped, and needs-attention findings are filtered by the permission of what each reports. The workflow instance endpoints (trends, recent activity, needs attention, hotspots) refuse callers holding neither `dashboard:view` nor `workflows/instances:view`, so the host requests them only for users holding one of those. A user with no visible widget who may read the runtime status still gets the overview; otherwise the host requests nothing.
- A user who may see no widget gets a welcome panel with shortcuts to the pages they can open, in navigation order, or the "No pages are available for your role" notice when there are none.

## Page States

The dashboard must handle:
Expand All @@ -566,6 +575,7 @@ The dashboard must handle:
- Empty workflow data.
- Refresh failure after previous successful data.
- No registered widget providers.
- No widget the user may see (welcome panel), distinguished from widgets still being registered (loading).
- A widget provider throwing during discovery.
- A widget component failing while other widgets remain renderable.

Expand Down Expand Up @@ -688,5 +698,5 @@ Phase 3:
- Should Studio include a limited fallback mode using existing workflow APIs before the backend dashboard API lands?
- Should dashboard data auto-refresh by default, or stay manual for predictable backend load?
- Should the dashboard expose an `include system workflows` toggle in the first version?
- Should runtime status be visible to every dashboard reader or only users with workflow instance read permission?
- Resolved: runtime status is visible to users holding `dashboard:view` or `workflows/runtime:view`, including a user with no visible widget, who sees it above the welcome shortcuts.
- Should a future version add user preferences for hiding unavailable widgets that providers elect to return?
1 change: 1 addition & 0 deletions src/modules/Elsa.Http.Webhooks/Elsa.Http.Webhooks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
</ItemGroup>

Expand Down
26 changes: 26 additions & 0 deletions src/modules/Elsa.Http.Webhooks/Permissions/WebhookPermissions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Elsa.Authorization;
using Elsa.Permissions;
using JetBrains.Annotations;

namespace Elsa.Http.Webhooks.Permissions;

/// <summary>
/// Stable resource names for webhook administration. The inbound <c>POST /webhooks</c> event sink
/// stays anonymous; this resource gates Studio's Webhooks page and menu.
/// </summary>
public static class WebhookPermissions
{
/// <summary>Manage webhook sinks and sources from Studio.</summary>
public const string Webhooks = "http/webhooks";
}

/// <summary>Contributes the Webhooks resource to the permission catalog so <c>*</c> administrators receive it.</summary>
[UsedImplicitly]
public sealed class WebhookPermissionsDescriptorProvider : IPermissionDescriptorProvider
{
/// <inheritdoc />
public IEnumerable<PermissionDescriptor> GetDescriptors() =>
[
new(WebhookPermissions.Webhooks, [CoreVerbs.View, CoreVerbs.Create, CoreVerbs.Update, CoreVerbs.Delete], "Webhooks", "Manage webhook sinks and sources.", "HTTP"),
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using Elsa.Studio.Authorization;
using Elsa.Studio.Contracts;
using Elsa.Studio.Models;
using Elsa.Studio.Services;
using Elsa.Studio.Testing;
using Xunit;

namespace Elsa.Studio.Core.Tests.Authorization;

/// <summary>
/// Uses a navigation shaped like the built-in modules' so the scenarios read like the reported one: a user whose
/// only role grants Secrets permissions.
/// </summary>
public class DefaultMenuServiceTests
{
private readonly MenuItem _dashboard = Item("Dashboard", "", new Permission("dashboard", "view"));
private readonly MenuItem _definitions = Item("Definitions", "workflows/definitions", new Permission("workflows/definitions", "view"));
private readonly MenuItem _instances = Item("Instances", "workflows/instances", new Permission("workflows/instances", "view"));
private readonly MenuItem _secrets = Item("Secrets", "security/secrets", new Permission("secrets", "view"));
private readonly MenuItem _webhooks = Item("Webhooks", "webhooks");
private readonly MenuItem _workflows;

public DefaultMenuServiceTests()
{
_workflows = Item("Workflows", "");
_workflows.SubMenuItems.Add(_definitions);
_workflows.SubMenuItems.Add(_instances);
}

[Fact]
public async Task ASecretsOnlyUser_SeesOnlySecretsAndUngatedItems()
{
var items = await GetMenuItemsAsync(new StubPermissionService("secrets:view", "secrets:write", "secrets:delete"));

Assert.Equal(["Secrets", "Webhooks"], items.Select(x => x.Text).Order());
}

[Fact]
public async Task AParentWhoseChildrenAreAllHidden_IsHidden()
{
var items = await GetMenuItemsAsync(new StubPermissionService("dashboard:view"));

Assert.DoesNotContain(items, x => x.Text == "Workflows");
}

[Fact]
public async Task AParent_KeepsOnlyItsPermittedChildren()
{
var items = await GetMenuItemsAsync(new StubPermissionService("workflows/instances:view"));

var workflows = Assert.Single(items, x => x.Text == "Workflows");
Assert.Equal(["Instances"], workflows.SubMenuItems.Select(x => x.Text));
}

[Fact]
public async Task AParentsOwnRequirement_HidesItRegardlessOfItsChildren()
{
_workflows.RequiredPermissions.Add(new("system/features", "view"));

var items = await GetMenuItemsAsync(new StubPermissionService("workflows/*:view"));

Assert.DoesNotContain(items, x => x.Text == "Workflows");
}

[Fact]
public async Task HierarchicalWildcards_AreHonored()
{
var items = await GetMenuItemsAsync(new StubPermissionService("workflows/*:view"));

var workflows = Assert.Single(items, x => x.Text == "Workflows");
Assert.Equal(["Definitions", "Instances"], workflows.SubMenuItems.Select(x => x.Text));
}

[Fact]
public async Task TheWildcardGrant_SeesEverything()
{
var items = await GetMenuItemsAsync(new StubPermissionService("*"));

Assert.Equal(["Dashboard", "Secrets", "Webhooks", "Workflows"], items.Select(x => x.Text).Order());
Assert.Equal(2, Assert.Single(items, x => x.Text == "Workflows").SubMenuItems.Count);
}

[Fact]
public async Task WhenPermissionsAreUnknown_EverythingIsShownAsBefore()
{
var items = await GetMenuItemsAsync(new StubPermissionService(UserPermissions.Unknown));

Assert.Equal(["Dashboard", "Secrets", "Webhooks", "Workflows"], items.Select(x => x.Text).Order());
}

[Fact]
public async Task WithoutAPermissionService_EverythingIsShownAsBefore()
{
var service = new DefaultMenuService([new StaticMenuProvider(_dashboard, _workflows)], []);

var items = (await service.GetMenuItemsAsync()).ToList();

Assert.Equal(2, items.Count);
}

[Fact]
public async Task Filtering_DoesNotMutateTheProvidersItems()
{
await GetMenuItemsAsync(new StubPermissionService("workflows/instances:view"));

Assert.Equal(2, _workflows.SubMenuItems.Count);
}

private async Task<IReadOnlyList<MenuItem>> GetMenuItemsAsync(IPermissionService permissionService)
{
var service = new DefaultMenuService([new StaticMenuProvider(_dashboard, _workflows, _secrets, _webhooks)], [], permissionService);
return (await service.GetMenuItemsAsync()).ToList();
}

private static MenuItem Item(string text, string href, params Permission[] requiredPermissions) =>
new() { Text = text, Href = href, RequiredPermissions = requiredPermissions.ToList() };

private sealed class StaticMenuProvider(params MenuItem[] items) : IMenuProvider
{
public ValueTask<IEnumerable<MenuItem>> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new(items);
}
}
Loading
Loading