diff --git a/doc/migrations/authorization-model.md b/doc/migrations/authorization-model.md index 78b421a379..58f1919c0b 100644 --- a/doc/migrations/authorization-model.md +++ b/doc/migrations/authorization-model.md @@ -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` instead of `IEnumerable`. The previous two-argument constructor that accepted the cache enumerable is gone. +- `JwtTokenProvider` gained an `IEnumerable` 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. diff --git a/doc/studio/AUTHENTICATION_ARCHITECTURE.md b/doc/studio/AUTHENTICATION_ARCHITECTURE.md index 18af5025f8..acda9393bd 100644 --- a/doc/studio/AUTHENTICATION_ARCHITECTURE.md +++ b/doc/studio/AUTHENTICATION_ARCHITECTURE.md @@ -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/ diff --git a/doc/studio/DASHBOARD_ARCHITECTURE_BLUEPRINT.md b/doc/studio/DASHBOARD_ARCHITECTURE_BLUEPRINT.md index e1f6cdc3f4..1ded84743a 100644 --- a/doc/studio/DASHBOARD_ARCHITECTURE_BLUEPRINT.md +++ b/doc/studio/DASHBOARD_ARCHITECTURE_BLUEPRINT.md @@ -110,7 +110,10 @@ public record DashboardWidgetDescriptor( Type ComponentType, string? Title = null, string? RequiredBackendCapability = null, - string? PayloadKind = null); + string? PayloadKind = null) +{ + public IReadOnlyCollection RequiredPermissions { get; init; } = []; +} ``` Descriptor fields: @@ -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 @@ -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 @@ -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 @@ -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. @@ -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. diff --git a/specs/studio/007-operational-dashboard/prd.md b/specs/studio/007-operational-dashboard/prd.md index b846bdf168..37126f6643 100644 --- a/specs/studio/007-operational-dashboard/prd.md +++ b/specs/studio/007-operational-dashboard/prd.md @@ -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: @@ -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 @@ -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: @@ -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. @@ -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? diff --git a/src/modules/Elsa.Http.Webhooks/Elsa.Http.Webhooks.csproj b/src/modules/Elsa.Http.Webhooks/Elsa.Http.Webhooks.csproj index 54e0358138..53cc833436 100644 --- a/src/modules/Elsa.Http.Webhooks/Elsa.Http.Webhooks.csproj +++ b/src/modules/Elsa.Http.Webhooks/Elsa.Http.Webhooks.csproj @@ -14,6 +14,7 @@ + diff --git a/src/modules/Elsa.Http.Webhooks/Permissions/WebhookPermissions.cs b/src/modules/Elsa.Http.Webhooks/Permissions/WebhookPermissions.cs new file mode 100644 index 0000000000..dd2cb2ea71 --- /dev/null +++ b/src/modules/Elsa.Http.Webhooks/Permissions/WebhookPermissions.cs @@ -0,0 +1,26 @@ +using Elsa.Authorization; +using Elsa.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Http.Webhooks.Permissions; + +/// +/// Stable resource names for webhook administration. The inbound POST /webhooks event sink +/// stays anonymous; this resource gates Studio's Webhooks page and menu. +/// +public static class WebhookPermissions +{ + /// Manage webhook sinks and sources from Studio. + public const string Webhooks = "http/webhooks"; +} + +/// Contributes the Webhooks resource to the permission catalog so * administrators receive it. +[UsedImplicitly] +public sealed class WebhookPermissionsDescriptorProvider : IPermissionDescriptorProvider +{ + /// + public IEnumerable GetDescriptors() => + [ + new(WebhookPermissions.Webhooks, [CoreVerbs.View, CoreVerbs.Create, CoreVerbs.Update, CoreVerbs.Delete], "Webhooks", "Manage webhook sinks and sources.", "HTTP"), + ]; +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs new file mode 100644 index 0000000000..16ef7ff137 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/DefaultMenuServiceTests.cs @@ -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; + +/// +/// 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. +/// +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> 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> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new(items); + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs new file mode 100644 index 0000000000..987feff324 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NavMenuTests.cs @@ -0,0 +1,108 @@ +using Bunit; +using Elsa.Studio.Authorization; +using Elsa.Studio.Components; +using Elsa.Studio.Contracts; +using Elsa.Studio.Models; +using Elsa.Studio.Services; +using Elsa.Studio.Testing; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +/// +/// The menu is filtered by the user's permissions, so it must be rebuilt when the authentication state changes rather +/// than keep the items it loaded when the shell started. +/// +public sealed class NavMenuTests : BunitContext, IAsyncLifetime +{ + private readonly SwitchableMenuService _menu = new(); + private readonly NotifyingAuthenticationStateProvider _authentication = new(); + + public NavMenuTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + Services.AddSingleton(_authentication); + } + + [Fact] + public void TheMenuIsRebuiltWhenTheAuthenticationStateChanges() + { + Services.AddSingleton(_menu); + _menu.Items = [Item("Workflows")]; + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Workflows", cut.Markup)); + + _menu.Items = [Item("Secrets")]; + _authentication.Notify(); + + cut.WaitForAssertion(() => + { + Assert.Contains("Secrets", cut.Markup); + Assert.DoesNotContain("Workflows", cut.Markup); + }); + } + + [Fact] + public void TheMenuIsRebuiltWhenThePermissionSnapshotChanges() + { + var permissions = new StubPermissionService("secrets:view"); + var cache = new TestPermissionSnapshotCache(); + Services.AddSingleton(permissions); + Services.AddSingleton(cache); + Services.AddSingleton(new DefaultMenuService( + [new StaticMenuProvider(new MenuItem + { + Text = "Secrets", + Href = "security/secrets", + GroupName = "general", + RequiredPermissions = [new Permission("secrets", PermissionVerbs.View)] + })], + [new StaticMenuGroupProvider(new MenuItemGroup("general", "General"))], + permissions)); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Secrets", cut.Markup)); + + permissions.Permissions = StubPermissionService.Grants(); + cache.Invalidate(); + + cut.WaitForAssertion(() => Assert.DoesNotContain("Secrets", cut.Markup)); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() => await base.DisposeAsync(); + + private static MenuItem Item(string text) => new() { Text = text, Href = text.ToLowerInvariant(), GroupName = "general" }; + + private sealed class SwitchableMenuService : IMenuService + { + public IReadOnlyList Items { get; set; } = []; + + public ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new(Items); + + public ValueTask> GetMenuItemGroupsAsync(CancellationToken cancellationToken = default) => + new([new MenuItemGroup("general", "General")]); + } + + private sealed class StaticMenuProvider(MenuItem item) : IMenuProvider + { + public ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new([item]); + } + + private sealed class StaticMenuGroupProvider(MenuItemGroup group) : IMenuGroupProvider + { + public ValueTask> GetMenuGroupsAsync(CancellationToken cancellationToken = default) => new([group]); + } + + private sealed class TestPermissionSnapshotCache : IPermissionSnapshotCache + { + public event EventHandler? Changed; + + public void Invalidate() => Changed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs new file mode 100644 index 0000000000..c22e79a2a5 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/NotifyingAuthenticationStateProvider.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Elsa.Studio.Core.Tests.Authorization; + +/// An authenticated user whose state change can be raised on demand. +internal sealed class NotifyingAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly AuthenticationState _state = new(new ClaimsPrincipal(new ClaimsIdentity("test"))); + + public override Task GetAuthenticationStateAsync() => Task.FromResult(_state); + + public void Notify() => NotifyAuthenticationStateChanged(Task.FromResult(_state)); +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs new file mode 100644 index 0000000000..15d70783b0 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionMatcherTests.cs @@ -0,0 +1,46 @@ +using Elsa.Studio.Authorization; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public class PermissionMatcherTests +{ + [Theory] + [InlineData("secrets:view", "secrets:view", true)] + [InlineData("secrets:view", "secrets:write", false)] + [InlineData("secrets:view", "workflows/definitions:view", false)] + [InlineData("*", "workflows/instances:delete", true)] + [InlineData("*:*", "secrets:write", true)] + [InlineData("*:view", "dashboard:view", true)] + [InlineData("*:view", "dashboard:write", false)] + [InlineData("secrets:*", "secrets:test", true)] + [InlineData("secrets:*", "labels:view", false)] + [InlineData("workflows/*:view", "workflows:view", true)] + [InlineData("workflows/*:view", "workflows/definitions:view", true)] + [InlineData("workflows/*:view", "workflows/definitions/versions:view", true)] + [InlineData("workflows/*:view", "workflows/definitions:write", false)] + [InlineData("workflows/*:view", "workflows-legacy:view", false)] + [InlineData("workflows/*:view", "workflowsx/definitions:view", false)] + [InlineData("workflows/definitions:view", "workflows/definitions/versions:view", false)] + [InlineData("workflows/definitions/*:*", "workflows/definitions:publish", true)] + [InlineData("workflows/definitions/*:*", "workflows/instances:view", false)] + [InlineData("workflows*:view", "workflows/definitions:view", false)] + [InlineData("identity/users:view", "identity/users:view", true)] + [InlineData("Secrets:view", "secrets:view", false)] + public void Satisfies_FollowsTheBackendMatchingRules(string granted, string required, bool expected) + { + Assert.True(Permission.TryParse(granted, out var grantedPermission)); + Assert.True(Permission.TryParse(required, out var requiredPermission)); + + Assert.Equal(expected, PermissionMatcher.Satisfies(grantedPermission, requiredPermission)); + } + + [Fact] + public void Satisfies_WithSeveralGrants_PassesWhenAnyGrantMatches() + { + Permission[] grants = [new("secrets", "view"), new("labels", "*")]; + + Assert.True(PermissionMatcher.Satisfies(grants, new("labels", "delete"))); + Assert.False(PermissionMatcher.Satisfies(grants, new("secrets", "delete"))); + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs new file mode 100644 index 0000000000..173fe4fcfa --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionPageGuardTests.cs @@ -0,0 +1,428 @@ +using Bunit; +using Elsa.Studio.Authorization; +using Elsa.Studio.Components; +using Elsa.Studio.Contracts; +using Elsa.Studio.Models; +using Elsa.Studio.Services; +using Elsa.Studio.Testing; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public sealed class PermissionPageGuardTests : BunitContext, IAsyncLifetime +{ + private const string PageContent = "page-content"; + private const string InstancesPath = "workflows/instances"; + + // While set, the landing menu does not resolve until it completes. + private Task? _menuHold; + + public PermissionPageGuardTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddMudServices(); + UseMenu(); + } + + [Fact] + public void AGatedPage_DoesNotRenderWhilePermissionsAreUnresolved() + { + var permissions = new PendingPermissionService(); + var cut = RenderGuard(permissions); + + Assert.DoesNotContain(PageContent, cut.Markup); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + } + + [Fact] + public void AnUngatedPage_RendersWhateverTheUserHolds() + { + var cut = RenderGuard(new StubPermissionService("secrets:view")); + + Assert.Contains(PageContent, cut.Markup); + } + + [Fact] + public void ThePage_ReceivesTheResolvedPermissions() + { + Services.AddSingleton(new StubPermissionService("secrets:view")); + + var cut = Render(parameters => parameters + .AddCascadingValue(Route()) + .AddChildContent()); + + var permissions = cut.FindComponent().Instance.Permissions; + Assert.NotNull(permissions); + Assert.True(permissions.Has("secrets", PermissionVerbs.View)); + Assert.False(permissions.Has("secrets", PermissionVerbs.Delete)); + } + + [Fact] + public void Navigating_ReusesTheResolvedPermissions() + { + var permissions = new StubPermissionService("workflows/instances:view"); + UsePermissions(permissions, "workflows/instances"); + var cut = Render>(RouteTo); + Assert.Contains(PageContent, cut.Markup); + + cut.Render(RouteTo); + + Assert.Contains("workflows/definitions:view", cut.Find("[data-testid='access-denied']").TextContent); + Assert.Equal(1, permissions.Calls); + } + + [Fact] + public void AGatedPage_RendersWhenTheUserHoldsEveryRequiredPermission() + { + var cut = RenderGuard(new StubPermissionService("workflows/*:view")); + + Assert.Contains(PageContent, cut.Markup); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + } + + [Fact] + public void AGatedPage_RendersAccessDeniedInsteadOfThePage_WhenAPermissionIsMissing() + { + var cut = RenderGuard(new StubPermissionService("secrets:view", "workflows/instances:view")); + + Assert.DoesNotContain(PageContent, cut.Markup); + var alert = cut.Find("[data-testid='access-denied']"); + Assert.Contains("workflows/definitions:view", alert.TextContent); + Assert.DoesNotContain("workflows/instances:view", alert.TextContent); + } + + [Fact] + public void AGatedPage_RendersAsBefore_WhenPermissionsAreUnknown() + { + var cut = RenderGuard(new StubPermissionService(UserPermissions.Unknown)); + + Assert.Contains(PageContent, cut.Markup); + } + + [Fact] + public void WithoutRouteData_TheContentRenders() + { + Services.AddSingleton(new StubPermissionService()); + + var cut = Render(parameters => parameters.AddChildContent(PageContent)); + + Assert.Contains(PageContent, cut.Markup); + } + + [Fact] + public void AMountedPage_FollowsPermissionChangesWhenTheAuthenticationStateChanges() + { + var permissions = new StubPermissionService("workflows/*:view"); + var authentication = new NotifyingAuthenticationStateProvider(); + Services.AddSingleton(authentication); + var cut = RenderGuard(permissions); + Assert.Contains(PageContent, cut.Markup); + + permissions.Permissions = StubPermissionService.Grants("secrets:view"); + authentication.Notify(); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-testid='access-denied']"))); + + permissions.Permissions = StubPermissionService.Grants("workflows/*:view"); + authentication.Notify(); + cut.WaitForAssertion(() => Assert.Contains(PageContent, cut.Markup)); + } + + [Fact] + public void TheLandingPage_SendsAUserWhoCannotViewItToTheirFirstAccessiblePage() + { + var cut = RenderGuard(new StubPermissionService("secrets:view"), "/"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/security/secrets", Navigation.Uri)); + Assert.DoesNotContain(PageContent, cut.Markup); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + } + + [Fact] + public void TheLandingPage_PicksTheFirstAccessiblePageInNavigationOrder() + { + // Secrets sorts first by item order but sits in a later group, so the navigation lists Workflows above it. + var cut = RenderGuard(new StubPermissionService("secrets:view", "workflows/definitions:view"), "/"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/workflows/definitions", Navigation.Uri)); + } + + [Fact] + public void TheLandingPage_RendersForAUserWhoCanViewIt() + { + var cut = RenderGuard(new StubPermissionService("dashboard:view"), "/"); + + Assert.Contains(PageContent, cut.Markup); + Assert.Equal("http://localhost/", Navigation.Uri); + } + + [Fact] + public void TheLandingPage_RendersAsBefore_WhenPermissionsAreUnknown() + { + var cut = RenderGuard(new StubPermissionService(UserPermissions.Unknown), "/"); + + Assert.Contains(PageContent, cut.Markup); + Assert.Equal("http://localhost/", Navigation.Uri); + } + + [Fact] + public void TheLandingPage_ExplainsThatNoPagesAreAvailable_WhenTheUserCanOpenNone() + { + var cut = RenderGuard(new StubPermissionService("unrelated:view"), "/"); + + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-testid='no-accessible-pages']"))); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + Assert.Equal("http://localhost/", Navigation.Uri); + } + + [Fact] + public void AnExplicitPageTheUserCanOpen_IsNotRedirected() + { + var cut = RenderGuard(new StubPermissionService("workflows/*:view", "secrets:view")); + + Assert.Contains(PageContent, cut.Markup); + Assert.Equal($"http://localhost/{InstancesPath}", Navigation.Uri); + } + + [Fact] + public void TheLandingPage_SkipsMenuItemsWithoutAnAppRelativeHref() + { + // Listed ahead of Secrets in its group, but neither has a page to open. + UseMenu( + new MenuItem { Text = "Header", GroupName = "administration", Order = -2 }, + new MenuItem { Text = "Docs", Href = "https://example.com/docs", GroupName = "administration", Order = -1 }); + + var cut = RenderGuard(new StubPermissionService("secrets:view"), "/"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/security/secrets", Navigation.Uri)); + } + + [Fact] + public void TheLandingPage_SkipsMenuItemsThatResolveToTheAppRoot() + { + UseMenu( + new MenuItem { Text = "Hash", Href = "#", GroupName = "administration", Order = -3 }, + new MenuItem { Text = "Query", Href = "?x", GroupName = "administration", Order = -2 }, + new MenuItem { Text = "Hash root", Href = "#/", GroupName = "administration", Order = -1 }); + + var cut = RenderGuard(new StubPermissionService("secrets:view"), "/"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/security/secrets", Navigation.Uri)); + } + + [Fact] + public async Task TheLandingPage_DoesNotRedirect_WhenTheUserNavigatesAwayWhileTheMenuLoads() + { + var menuLoaded = new TaskCompletionSource(); + _menuHold = menuLoaded.Task; + var cut = RenderGuard(new StubPermissionService("secrets:view"), "/"); + + Navigation.NavigateTo(InstancesPath); + menuLoaded.SetResult(); + await cut.InvokeAsync(() => { }); + + Assert.Equal($"http://localhost/{InstancesPath}", Navigation.Uri); + } + + [Fact] + public async Task TheLandingPage_DoesNotRedirect_WhenTheUserIsGrantedItWhileTheMenuLoads() + { + var menuLoaded = new TaskCompletionSource(); + _menuHold = menuLoaded.Task; + var permissions = new StubPermissionService("secrets:view"); + var authentication = new NotifyingAuthenticationStateProvider(); + Services.AddSingleton(authentication); + var cut = RenderGuard(permissions, "/"); + + permissions.Permissions = StubPermissionService.Grants("secrets:view", "dashboard:view"); + authentication.Notify(); + cut.WaitForAssertion(() => Assert.Contains(PageContent, cut.Markup)); + menuLoaded.SetResult(); + await cut.InvokeAsync(() => { }); + + Assert.Equal("http://localhost/", Navigation.Uri); + } + + [Fact] + public void TheLandingPage_ExplainsThatNoPagesAreAvailable_WhenTheMenuRecoversAfterAFailure() + { + var permissions = new StubPermissionService("secrets:view"); + var authentication = new NotifyingAuthenticationStateProvider(); + Services.AddSingleton(authentication); + Services.AddSingleton(new UnavailableMenuService(failures: 1)); + var cut = RenderGuard(permissions, "/"); + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-testid='access-denied']"))); + + permissions.Permissions = StubPermissionService.Grants("unrelated:view"); + authentication.Notify(); + + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-testid='no-accessible-pages']"))); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + } + + [Fact] + public void TheLandingPage_StillRedirects_WhenTheUrlCarriesAQueryString() + { + var cut = RenderGuard(new StubPermissionService("secrets:view"), "/?tab=1"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/security/secrets", Navigation.Uri)); + } + + [Fact] + public void TheLandingPage_RedirectsRelativeToTheBaseUri_WhenHostedUnderASubPath() + { + var navigation = new SubPathNavigationManager(); + Services.AddSingleton(navigation); + + var cut = RenderGuard(new StubPermissionService("secrets:view"), "http://localhost/studio/"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/studio/security/secrets", navigation.Uri)); + Assert.Empty(cut.FindAll("[data-testid='access-denied']")); + } + + [Fact] + public void TheLandingPage_StaysInsideTheApp_WhenTheFirstMenuHrefStartsWithASlash() + { + var navigation = new SubPathNavigationManager(); + Services.AddSingleton(navigation); + UseMenu(new MenuItem { Text = "Rooted", Href = "/security/rooted", GroupName = "administration", Order = -1 }); + + var cut = RenderGuard(new StubPermissionService("secrets:view"), "http://localhost/studio/"); + + cut.WaitForAssertion(() => Assert.Equal("http://localhost/studio/security/rooted", navigation.Uri)); + } + + [Fact] + public void AnExplicitPage_IsNotRedirected_EvenWhenTheLandingPageIsDenied() + { + var cut = RenderGuard(new StubPermissionService("secrets:view"), InstancesPath); + + Assert.NotEmpty(cut.FindAll("[data-testid='access-denied']")); + Assert.Equal($"http://localhost/{InstancesPath}", Navigation.Uri); + } + + [Fact] + public void TheLandingPage_ShowsAccessDenied_WhenTheMenuCannotBeResolved() + { + Services.AddSingleton(new UnavailableMenuService()); + + var cut = RenderGuard(new StubPermissionService("secrets:view"), "/"); + + cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-testid='access-denied']"))); + Assert.Empty(cut.FindAll("[data-testid='no-accessible-pages']")); + Assert.Equal("http://localhost/", Navigation.Uri); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() => await base.DisposeAsync(); + + // Starts on a page other than the landing page by default, since only the landing page redirects. + private IRenderedComponent RenderGuard(IPermissionService permissionService, string startPath = InstancesPath) + { + UsePermissions(permissionService, startPath); + + return Render(parameters => parameters + .AddCascadingValue(Route()) + .AddChildContent(PageContent)); + } + + private void UsePermissions(IPermissionService permissionService, string startPath) + { + Services.AddSingleton(permissionService); + Navigation.NavigateTo(startPath); + } + + private void UseMenu(params MenuItem[] extraItems) + { + var menu = new LandingMenu(extraItems, () => _menuHold); + Services.AddSingleton(sp => new DefaultMenuService([menu], [menu], sp.GetService())); + } + + private NavigationManager Navigation => Services.GetRequiredService(); + + // CascadingValue takes a complete parameter set, so every render passes the guard along with the route. + private static void RouteTo(ComponentParameterCollectionBuilder> parameters) => parameters + .Add(x => x.Value, Route()) + .AddChildContent(guard => guard.AddChildContent(PageContent)); + + private static RouteData Route() => new(typeof(TPage), new Dictionary()); + + // Fails the first `failures` times the menu is requested, then has nothing to show. + private sealed class UnavailableMenuService(int failures = int.MaxValue) : IMenuService + { + private int _calls; + + public ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) => new(Enumerable.Empty()); + + public ValueTask> GetMenuItemGroupsAsync(CancellationToken cancellationToken = default) => + _calls++ < failures ? throw new InvalidOperationException("menu unavailable") : new(Enumerable.Empty()); + } + + // An application hosted under /studio/, which bUnit's fake navigation manager (always rooted at /) cannot model. + private sealed class SubPathNavigationManager : NavigationManager + { + public SubPathNavigationManager() => Initialize("http://localhost/studio/", "http://localhost/studio/"); + + protected override void NavigateToCore(string uri, NavigationOptions options) => Uri = ToAbsoluteUri(uri).ToString(); + } + + private sealed class UngatedPage : ComponentBase + { + } + + [RequirePermission("dashboard", PermissionVerbs.View)] + private sealed class DashboardPage : ComponentBase + { + } + + // Navigation shaped like the built-in modules': Secrets is listed first but belongs to a later group. + private sealed class LandingMenu(MenuItem[] extraItems, Func hold) : IMenuProvider, IMenuGroupProvider + { + public async ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) + { + if (hold() is { } pending) + await pending; + + return new[] + { + Item("Secrets", "security/secrets", "administration", 0, new Permission("secrets", PermissionVerbs.View)), + Item("Workflows", "", "general", 10, subMenuItems: [Item("Definitions", "workflows/definitions", "general", 0, new Permission("workflows/definitions", PermissionVerbs.View))]), + Item("Dashboard", "", "general", 0, new Permission("dashboard", PermissionVerbs.View)) + }.Concat(extraItems); + } + + public ValueTask> GetMenuGroupsAsync(CancellationToken cancellationToken = default) => new(new[] + { + new MenuItemGroup("administration", "Administration", 1000f), + new MenuItemGroup("general", "General") + }); + + private static MenuItem Item(string text, string href, string group, float order, Permission? permission = null, MenuItem[]? subMenuItems = null) => new() + { + Text = text, Href = href, GroupName = group, Order = order, + RequiredPermissions = permission is { } required ? [required] : [], + SubMenuItems = subMenuItems ?? [] + }; + } + + private sealed class PermissionsConsumer : ComponentBase + { + [CascadingParameter] public UserPermissions? Permissions { get; set; } + } + + [RequirePermission("workflows/instances", PermissionVerbs.View)] + [RequirePermission("workflows/definitions", PermissionVerbs.View)] + private sealed class WorkflowInstancesPage : ComponentBase + { + } + + private sealed class PendingPermissionService : IPermissionService + { + public ValueTask GetPermissionsAsync(CancellationToken cancellationToken = default) => + new(new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task); + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs new file mode 100644 index 0000000000..0a5b8a6ebc --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionTests.cs @@ -0,0 +1,36 @@ +using Elsa.Studio.Authorization; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public class PermissionTests +{ + [Theory] + [InlineData("secrets:view", "secrets", "view")] + [InlineData(" workflows/*:view ", "workflows/*", "view")] + [InlineData("*", "*", "*")] + [InlineData("*:view", "*", "view")] + public void TryParse_AcceptsWellFormedPermissions(string value, string resource, string verb) + { + Assert.True(Permission.TryParse(value, out var permission)); + Assert.Equal(new Permission(resource, verb), permission); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("secrets")] + [InlineData(":view")] + [InlineData("secrets:")] + [InlineData("external-authentication:connections:read")] + [InlineData("workflows:definitions/view")] + [InlineData("secrets,labels:view")] + public void TryParse_RejectsMalformedPermissions(string? value) + { + Assert.False(Permission.TryParse(value, out _)); + } + + [Fact] + public void ToString_WritesResourceAndVerb() => Assert.Equal("workflows/instances:view", new Permission("workflows/instances", "view").ToString()); +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionViewTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionViewTests.cs new file mode 100644 index 0000000000..5a905d8fb5 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/PermissionViewTests.cs @@ -0,0 +1,176 @@ +using Bunit; +using Elsa.Studio.Authorization; +using Elsa.Studio.Components; +using Elsa.Studio.Testing; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public sealed class PermissionViewTests : BunitContext, IAsyncLifetime +{ + private const string Action = "create-secret"; + private const string Fallback = "no-access"; + + private readonly StubPermissionService _permissions = new("secrets:view"); + private readonly NotifyingAuthenticationStateProvider _authentication = new(); + + public PermissionViewTests() + { + Services.AddSingleton(_permissions); + Services.AddSingleton(_authentication); + } + + [Fact] + public void ThePageGrantsTheRequiredPermission_TheContentRendersWithoutResolvingPermissionsAgain() + { + var cut = RenderView(StubPermissionService.Grants("secrets:*"), PermissionVerbs.Write); + + Assert.Contains(Action, cut.Markup); + Assert.Equal(0, _permissions.Calls); + } + + [Fact] + public void ThePageLacksTheRequiredPermission_TheDeniedContentRendersInstead() + { + var cut = RenderView(StubPermissionService.Grants("secrets:view"), PermissionVerbs.Write); + + Assert.DoesNotContain(Action, cut.Markup); + Assert.Contains(Fallback, cut.Markup); + } + + [Fact] + public void PermissionsAreUnknown_TheContentRendersAsBefore() + { + var cut = RenderView(UserPermissions.Unknown, PermissionVerbs.Delete); + + Assert.Contains(Action, cut.Markup); + } + + [Fact] + public void EveryPermissionIsRequired() + { + Permission[] required = [new("secrets", PermissionVerbs.Write), new("secrets", PermissionVerbs.Delete)]; + + var denied = RenderView(StubPermissionService.Grants("secrets:write"), allOf: required); + var permitted = RenderView(StubPermissionService.Grants("secrets:write", "secrets:delete"), allOf: required); + + Assert.DoesNotContain(Action, denied.Markup); + Assert.Contains(Action, permitted.Markup); + } + + [Fact] + public void OutsideAPage_DoesNotRenderWhilePermissionsAreUnresolved() + { + var permissions = new PendingPermissionService(); + Services.AddSingleton(permissions); + + var cut = RenderView(pagePermissions: null, PermissionVerbs.Write); + + Assert.DoesNotContain(Action, cut.Markup); + Assert.DoesNotContain(Fallback, cut.Markup); + } + + [Fact] + public void OutsideAPage_ThePermissionsAreResolvedAndFollowAuthenticationChanges() + { + var cut = RenderView(pagePermissions: null, PermissionVerbs.Write); + Assert.DoesNotContain(Action, cut.Markup); + Assert.Equal(1, _permissions.Calls); + + _permissions.Permissions = StubPermissionService.Grants("secrets:write"); + _authentication.Notify(); + cut.WaitForAssertion(() => Assert.Contains(Action, cut.Markup)); + + _permissions.Permissions = StubPermissionService.Grants("secrets:view"); + _authentication.Notify(); + cut.WaitForAssertion(() => Assert.DoesNotContain(Action, cut.Markup)); + } + + [Fact] + public void OutsideAPage_AResolutionOvertakenByANewerOneIsIgnored() + { + var permissions = new PendingPermissionService(); + Services.AddSingleton(permissions); + var cut = RenderView(pagePermissions: null, PermissionVerbs.Write); + + _authentication.Notify(); + cut.WaitForState(() => permissions.Pending == 2); + permissions.Complete(1, StubPermissionService.Grants("secrets:write")); + cut.WaitForAssertion(() => Assert.Contains(Action, cut.Markup)); + + // Completing the first resolution lets initialization finish, which renders once more. + var renders = cut.RenderCount; + permissions.Complete(0, StubPermissionService.Grants("secrets:view")); + cut.WaitForState(() => cut.RenderCount > renders); + + Assert.Contains(Action, cut.Markup); + } + + [Fact] + public async Task Disposed_StopsFollowingAuthenticationChanges() + { + RenderView(pagePermissions: null, PermissionVerbs.Write); + + await DisposeComponentsAsync(); + _authentication.Notify(); + + Assert.Equal(1, _permissions.Calls); + } + + [Fact] + public void Wrap_GatesContentBuiltInCode() + { + var fragment = PermissionView.Wrap("secrets", PermissionVerbs.Write, builder => builder.AddContent(0, Action)); + + var denied = Render(fragment); + _permissions.Permissions = StubPermissionService.Grants("secrets:write"); + var permitted = Render(fragment); + + Assert.DoesNotContain(Action, denied.Markup); + Assert.Contains(Action, permitted.Markup); + } + + [Fact] + public void AResourceWithoutAVerb_IsRejected() => + Assert.Throws(() => Render(parameters => parameters + .AddCascadingValue(UserPermissions.Unknown) + .Add(x => x.Resource, "secrets"))); + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() => await base.DisposeAsync(); + + private IRenderedComponent RenderView(UserPermissions? pagePermissions, string? verb = null, IEnumerable? allOf = null) => + Render(parameters => + { + if (pagePermissions != null) + parameters.AddCascadingValue(pagePermissions); + + if (verb != null) + parameters.Add(x => x.Resource, "secrets").Add(x => x.Verb, verb); + + parameters + .Add(x => x.AllOf, allOf) + .Add(x => x.Denied, Fallback) + .AddChildContent(Action); + }); + + /// Hands out resolutions that stay pending until the test completes them, in any order. + private sealed class PendingPermissionService : IPermissionService + { + private readonly List> _resolutions = []; + + public ValueTask GetPermissionsAsync(CancellationToken cancellationToken = default) + { + var resolution = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _resolutions.Add(resolution); + return new(resolution.Task); + } + + public int Pending => _resolutions.Count; + + public void Complete(int index, UserPermissions permissions) => _resolutions[index].SetResult(permissions); + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/UserPermissionsTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/UserPermissionsTests.cs new file mode 100644 index 0000000000..bc6338a34c --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Authorization/UserPermissionsTests.cs @@ -0,0 +1,38 @@ +using Elsa.Studio.Authorization; +using Elsa.Studio.Testing; +using Xunit; + +namespace Elsa.Studio.Core.Tests.Authorization; + +public class UserPermissionsTests +{ + private static readonly Permission[] DashboardOrInstances = [new("dashboard", PermissionVerbs.View), new("workflows/instances", PermissionVerbs.View)]; + + [Theory] + [InlineData("workflows/instances:view", true)] + [InlineData("workflows/*:view", true)] + [InlineData("dashboard:view", true)] + [InlineData("secrets:view", false)] + [InlineData(null, true)] + public void HasAny_PassesWhenOneOfTheRequiredPermissionsIsHeld(string? grant, bool expected) + { + var permissions = grant == null ? UserPermissions.Unknown : StubPermissionService.Grants(grant); + + Assert.Equal(expected, permissions.HasAny(DashboardOrInstances)); + } + + [Theory] + [InlineData(new[] { "secrets:view", "labels:view" }, new[] { "labels:view", "secrets:view" }, true)] + [InlineData(new[] { "secrets:view" }, new[] { "secrets:view", "labels:view" }, false)] + [InlineData(new[] { "secrets:view" }, new[] { "labels:view" }, false)] + public void IsEquivalentTo_ComparesTheGrantsRegardlessOfOrder(string[] grants, string[] otherGrants, bool expected) => + Assert.Equal(expected, StubPermissionService.Grants(grants).IsEquivalentTo(StubPermissionService.Grants(otherGrants))); + + [Fact] + public void IsEquivalentTo_TellsUnknownApartFromKnownWithoutGrants() + { + Assert.True(UserPermissions.Unknown.IsEquivalentTo(UserPermissions.Unknown)); + Assert.False(UserPermissions.Unknown.IsEquivalentTo(StubPermissionService.Grants())); + Assert.False(StubPermissionService.Grants().IsEquivalentTo(null)); + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs new file mode 100644 index 0000000000..5350ef93b7 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core.Tests/DefaultFeatureServiceTests.cs @@ -0,0 +1,60 @@ +using Elsa.Studio.Contracts; +using Elsa.Studio.Services; +using Xunit; + +namespace Elsa.Studio.Core.Tests; + +/// +/// stays a default interface member so third-party +/// implementations compiled against earlier Studio packages keep working. The stock service reports +/// initialized only after completes. +/// +public class DefaultFeatureServiceTests +{ + [Fact] + public async Task InitializeFeatures_ReportsItIsInitialized_ByTheTimeInitializedIsRaised() + { + IFeatureService service = new DefaultFeatureService([new LocalFeature()], new EmptyRemoteFeatureProvider()); + var initializedWhenRaised = false; + service.Initialized += () => initializedWhenRaised = service.IsInitialized; + Assert.False(service.IsInitialized); + + await service.InitializeFeaturesAsync(); + + Assert.True(initializedWhenRaised); + Assert.True(service.IsInitialized); + } + + [Fact] + public void UnimplementedIsInitialized_DefaultsToFalse() + { + IFeatureService service = new LegacyFeatureService(); + + Assert.False(service.IsInitialized); + } + + private sealed class LocalFeature : IFeature + { + public ValueTask InitializeAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + } + + private sealed class EmptyRemoteFeatureProvider : IRemoteFeatureProvider + { + public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => Task.FromResult(false); + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + } + + /// Simulates a third-party implementation compiled against the default interface member. + private sealed class LegacyFeatureService : IFeatureService + { + public event Action? Initialized + { + add { } + remove { } + } + + public IEnumerable GetFeatures() => []; + public Task InitializeFeaturesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj b/src/studio/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj index 5315ab2bce..04968911d1 100644 --- a/src/studio/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj +++ b/src/studio/framework/Elsa.Studio.Core.Tests/Elsa.Studio.Core.Tests.csproj @@ -8,17 +8,24 @@ + + + + + + + diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/HostAuthenticationConfigurationTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/HostAuthenticationConfigurationTests.cs index 2096c1a6ac..f9d80ba544 100644 --- a/src/studio/framework/Elsa.Studio.Core.Tests/HostAuthenticationConfigurationTests.cs +++ b/src/studio/framework/Elsa.Studio.Core.Tests/HostAuthenticationConfigurationTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using Xunit; namespace Elsa.Studio.Core.Tests; @@ -19,4 +20,14 @@ public void ServerHostShipsWithRunnableAuthenticationDefaults() .GetProperty("ClientSecret") .GetString()); } + + [Fact] + public void ServerHostPersistsAntiforgeryStateForSignOut() + { + var host = CssContractTestContext.ReadRepositoryFile( + "src", "hosts", "Elsa.Studio.Host.Server", "Pages", "_Host.cshtml"); + var markup = Regex.Replace(host, @"@\*.*?\*@|", string.Empty, RegexOptions.Singleline); + + Assert.Matches(@"", markup); + } } diff --git a/src/studio/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs b/src/studio/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs index ff2ed7639f..35eb6d21c0 100644 --- a/src/studio/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs +++ b/src/studio/framework/Elsa.Studio.Core.Tests/StudioShellCssContractTests.cs @@ -52,13 +52,11 @@ public void NavigationGroupLabelsUseComfortableLogicalInset() } [Theory] - [InlineData("Elsa.Studio.Login", "Components", "LoginState.razor")] - [InlineData("Elsa.Studio.Environments", "Components", "EnvironmentPicker.razor")] - [InlineData("Elsa.Studio.ExternalAuthentication.BlazorServer", "Components", "BrokerLoginState.razor")] - [InlineData("Elsa.Studio.ExternalAuthentication.BlazorWasm", "Components", "BrokerLoginState.razor")] + [InlineData("framework", "Elsa.Studio.Shared", "Components", "AppBar", "UserMenu.razor")] + [InlineData("modules", "Elsa.Studio.Environments", "Components", "EnvironmentPicker.razor")] public void AccountMenusInheritShellUtilityColor(params string[] componentPath) { - var path = new[] { "src", "modules" }.Concat(componentPath).ToArray(); + var path = new[] { "src" }.Concat(componentPath).ToArray(); var component = CssContractTestContext.ReadRepositoryFile(path); Assert.Contains("IconColor=\"Color.Inherit\"", component, StringComparison.Ordinal); diff --git a/src/studio/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs b/src/studio/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs new file mode 100644 index 0000000000..8227babfc1 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Authorization/IPermissionService.cs @@ -0,0 +1,14 @@ +namespace Elsa.Studio.Authorization; + +/// +/// Resolves the current user's permissions so Studio can hide navigation, pages and actions the user cannot use. +/// +public interface IPermissionService +{ + /// + /// Returns the current user's permissions. Production adapters fail closed with a known empty set + /// when the snapshot is unavailable. is only for hosts that + /// have not registered an adapter. + /// + ValueTask GetPermissionsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/studio/framework/Elsa.Studio.Core/Authorization/Permission.cs b/src/studio/framework/Elsa.Studio.Core/Authorization/Permission.cs new file mode 100644 index 0000000000..59b26af55c --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Authorization/Permission.cs @@ -0,0 +1,64 @@ +namespace Elsa.Studio.Authorization; + +/// +/// A permission: a hierarchical resource path paired with a verb, written {resource}:{verb}. +/// +/// +/// Mirrors the backend's permission grammar so Studio evaluates grants exactly as the server does. A trailing +/// /* on the resource matches the named node and every descendant, a bare * resource matches +/// everything, and * as a verb matches any verb. +/// +public readonly record struct Permission(string Resource, string Verb) +{ + /// Separates the resource from the verb. + public const char Separator = ':'; + + /// Separates resource path segments. + public const char PathSeparator = '/'; + + /// Matches any resource, or any verb, depending on the axis it appears on. + public const string Wildcard = "*"; + + /// The whole vocabulary. + public static Permission All { get; } = new(Wildcard, Wildcard); + + /// + /// Parses , returning false when it is not a well-formed permission. A bare + /// * normalizes to . + /// + public static bool TryParse(string? value, out Permission permission) + { + permission = default; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + var trimmed = value.Trim(); + + if (trimmed == Wildcard) + { + permission = All; + return true; + } + + if (trimmed.Contains(',')) + return false; + + var separator = trimmed.IndexOf(Separator); + + if (separator <= 0 || separator == trimmed.Length - 1) + return false; + + var resource = trimmed[..separator]; + var verb = trimmed[(separator + 1)..]; + + if (verb.IndexOf(Separator) >= 0 || verb.IndexOf(PathSeparator) >= 0) + return false; + + permission = new(resource, verb); + return true; + } + + /// + public override string ToString() => $"{Resource}{Separator}{Verb}"; +} diff --git a/src/studio/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs b/src/studio/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs new file mode 100644 index 0000000000..cc448f5789 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Authorization/PermissionMatcher.cs @@ -0,0 +1,41 @@ +namespace Elsa.Studio.Authorization; + +/// +/// Decides whether a held permission satisfies a required one, using the same rules as the backend evaluator: +/// exact match, or a wildcard on either axis. +/// +public static class PermissionMatcher +{ + /// Whether satisfies . + public static bool Satisfies(Permission granted, Permission required) => + ResourceMatches(granted.Resource, required.Resource) && VerbMatches(granted.Verb, required.Verb); + + /// Whether any of satisfies . + public static bool Satisfies(IEnumerable granted, Permission required) => + granted.Any(x => Satisfies(x, required)); + + /// + /// Whether a granted resource pattern covers a required resource. A trailing /* matches the named node + /// and every descendant at any depth; a bare * matches everything. + /// + public static bool ResourceMatches(string granted, string required) + { + if (granted == Permission.Wildcard || string.Equals(granted, required, StringComparison.Ordinal)) + return true; + + if (!granted.EndsWith($"{Permission.PathSeparator}{Permission.Wildcard}", StringComparison.Ordinal)) + return false; + + // 'workflows/*' covers 'workflows' itself as well as everything beneath it. + var prefix = granted[..^2]; + + return string.Equals(prefix, required, StringComparison.Ordinal) + || required.Length > prefix.Length + && required.StartsWith(prefix, StringComparison.Ordinal) + && required[prefix.Length] == Permission.PathSeparator; + } + + /// Whether a granted verb covers a required verb. + public static bool VerbMatches(string granted, string required) => + granted == Permission.Wildcard || string.Equals(granted, required, StringComparison.Ordinal); +} diff --git a/src/studio/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs b/src/studio/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs new file mode 100644 index 0000000000..bf17c91315 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Authorization/PermissionVerbs.cs @@ -0,0 +1,23 @@ +namespace Elsa.Studio.Authorization; + +/// The backend's recommended core verbs. +public static class PermissionVerbs +{ + /// Read, list, query, inspect, export. + public const string View = "view"; + + /// Bring a new record into existence. + public const string Create = "create"; + + /// Modify an existing record. + public const string Update = "update"; + + /// Create or modify, where the API does not separate the two. + public const string Write = "write"; + + /// Remove a record. + public const string Delete = "delete"; + + /// Run, dispatch, or invoke against a live system. + public const string Execute = "execute"; +} diff --git a/src/studio/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs b/src/studio/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs new file mode 100644 index 0000000000..1662f66ca4 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Authorization/RequirePermissionAttribute.cs @@ -0,0 +1,19 @@ +namespace Elsa.Studio.Authorization; + +/// +/// Declares a permission a routable page requires. Studio's shell renders an access-denied state instead of the +/// page, so the page never issues API calls the backend would reject. Multiple attributes are all required. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class RequirePermissionAttribute(string resource, string verb) : Attribute +{ + /// The required permission. + public Permission Permission { get; } = new(resource, verb); + + /// Returns every permission declared on , including inherited declarations. + public static IReadOnlyCollection GetRequiredPermissions(Type type) => + type.GetCustomAttributes(typeof(RequirePermissionAttribute), inherit: true) + .Cast() + .Select(x => x.Permission) + .ToArray(); +} diff --git a/src/studio/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs b/src/studio/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs new file mode 100644 index 0000000000..cbc4a509df --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Authorization/UserPermissions.cs @@ -0,0 +1,51 @@ +namespace Elsa.Studio.Authorization; + +/// +/// The permissions the current user holds, used only to tailor what Studio renders. The backend re-evaluates +/// every request, so this is never an authorization decision. +/// +public sealed class UserPermissions +{ + private UserPermissions(bool isKnown, IReadOnlyCollection grants) + { + IsKnown = isKnown; + Grants = grants; + } + + /// + /// No permission information is available (for example, an authentication provider whose tokens carry no + /// permissions claims). Every check passes, so Studio behaves as it did before permission gating. + /// + public static UserPermissions Unknown { get; } = new(false, []); + + /// Creates a known permission set from the user's grants. + public static UserPermissions FromGrants(IEnumerable grants) => new(true, grants.Distinct().ToArray()); + + /// Whether permission information is available. When it is not, every check passes. + public bool IsKnown { get; } + + /// The grants held by the user. Empty when is false. + public IReadOnlyCollection Grants { get; } + + /// Whether the user holds a grant satisfying . + public bool Has(Permission required) => !IsKnown || PermissionMatcher.Satisfies(Grants, required); + + /// Whether the user holds a grant satisfying {resource}:{verb}. + public bool Has(string resource, string verb) => Has(new Permission(resource, verb)); + + /// Whether the user holds grants satisfying every one of . + public bool HasAll(IEnumerable required) => required.All(Has); + + /// Whether the user holds a grant satisfying at least one of . + public bool HasAny(IEnumerable required) => required.Any(Has); + + /// The permissions in the user does not hold. + public IReadOnlyCollection GetMissing(IEnumerable required) => required.Where(x => !Has(x)).ToArray(); + + /// + /// Whether describes the same permissions: both unknown, or both known with the same grants. + /// A renewed sign-in resolves a new instance even when nothing changed, so compare with this rather than by reference. + /// + public bool IsEquivalentTo(UserPermissions? other) => + other != null && IsKnown == other.IsKnown && Grants.Count == other.Grants.Count && Grants.All(other.Grants.Contains); +} diff --git a/src/studio/framework/Elsa.Studio.Core/Contracts/IFeatureService.cs b/src/studio/framework/Elsa.Studio.Core/Contracts/IFeatureService.cs index a068d373d4..6ff2ff7d1a 100644 --- a/src/studio/framework/Elsa.Studio.Core/Contracts/IFeatureService.cs +++ b/src/studio/framework/Elsa.Studio.Core/Contracts/IFeatureService.cs @@ -9,6 +9,15 @@ public interface IFeatureService /// Event that is triggered when the features have been initialized. /// event Action? Initialized; + + /// + /// Whether has completed. + /// + /// + /// Default is false so third-party implementations and decorators compiled against + /// earlier Studio packages keep working. Making this member abstract would be a binary break. + /// + bool IsInitialized => false; /// /// Returns all features. diff --git a/src/studio/framework/Elsa.Studio.Core/Contracts/IPermissionRefreshSignal.cs b/src/studio/framework/Elsa.Studio.Core/Contracts/IPermissionRefreshSignal.cs new file mode 100644 index 0000000000..475fbb02ea --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Contracts/IPermissionRefreshSignal.cs @@ -0,0 +1,20 @@ +namespace Elsa.Studio.Contracts; + +/// +/// A per-circuit signal that the current principal's tokens or backend identity just changed +/// without an AuthenticationStateChanged notification (silent JWT refresh or environment switch). +/// +/// +/// The signal has no dependencies so Environment and Identity modules can raise it without +/// constructing the permission cache (which would cycle through the backend client). +/// It is scoped so one Blazor Server circuit's refresh or environment switch does not +/// invalidate other users' caches. +/// +public interface IPermissionRefreshSignal +{ + /// Raised after a token refresh or environment switch that should drop cached grants. + event Action? Raised; + + /// Notifies subscribers that cached grants are no longer valid. + void Raise(); +} diff --git a/src/studio/framework/Elsa.Studio.Core/Contracts/IPermissionSnapshotCache.cs b/src/studio/framework/Elsa.Studio.Core/Contracts/IPermissionSnapshotCache.cs new file mode 100644 index 0000000000..59df291b97 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Contracts/IPermissionSnapshotCache.cs @@ -0,0 +1,16 @@ +namespace Elsa.Studio.Contracts; + +/// +/// Clears a cached permission snapshot so the next resolution reloads grants for the current principal and backend. +/// +public interface IPermissionSnapshotCache +{ + /// + /// Raised after so permission-dependent UI can re-fetch. + /// Subscribers run after the snapshot is dropped, so a re-resolve cannot read the previous grants. + /// + event EventHandler? Changed; + + /// Drops any cached snapshot, including Forbidden and Unavailable, then raises . + void Invalidate(); +} diff --git a/src/studio/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs b/src/studio/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs index e30008c1ec..735d8d39c0 100644 --- a/src/studio/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/studio/framework/Elsa.Studio.Core/Extensions/ServiceCollectionExtensions.cs @@ -77,6 +77,10 @@ public static IServiceCollection AddCoreInternal( // Single-flight coordinator for preventing concurrent operations. services.TryAddScoped(); + // Per-circuit refresh/environment signal. Has no dependencies so Security and + // Environments can raise it without constructing the permission cache. + services.TryAddScoped(); + return services; } diff --git a/src/studio/framework/Elsa.Studio.Core/Models/MenuItem.cs b/src/studio/framework/Elsa.Studio.Core/Models/MenuItem.cs index 6b8bff2481..a178499618 100644 --- a/src/studio/framework/Elsa.Studio.Core/Models/MenuItem.cs +++ b/src/studio/framework/Elsa.Studio.Core/Models/MenuItem.cs @@ -1,3 +1,4 @@ +using Elsa.Studio.Authorization; using Microsoft.AspNetCore.Components.Routing; namespace Elsa.Studio.Models; @@ -41,4 +42,17 @@ public class MenuItem /// The name of the group this menu item belongs to. /// public string GroupName { get; set; } = "General"; + + /// + /// The permissions the current user must hold for this item to be shown. All are required. An item whose + /// sub menu items are all hidden is hidden as well. + /// + public ICollection RequiredPermissions { get; set; } = new List(); + + internal MenuItem WithSubMenuItems(ICollection subMenuItems) + { + var clone = (MenuItem)MemberwiseClone(); + clone.SubMenuItems = subMenuItems; + return clone; + } } \ No newline at end of file diff --git a/src/studio/framework/Elsa.Studio.Core/Models/MenuNavigationGroup.cs b/src/studio/framework/Elsa.Studio.Core/Models/MenuNavigationGroup.cs new file mode 100644 index 0000000000..4a9f83baba --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Models/MenuNavigationGroup.cs @@ -0,0 +1,4 @@ +namespace Elsa.Studio.Models; + +/// A menu item group with the items that belong to it, in display order. +public record MenuNavigationGroup(MenuItemGroup Group, IReadOnlyList Items); diff --git a/src/studio/framework/Elsa.Studio.Core/Models/MenuPage.cs b/src/studio/framework/Elsa.Studio.Core/Models/MenuPage.cs new file mode 100644 index 0000000000..4993e805bf --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Models/MenuPage.cs @@ -0,0 +1,4 @@ +namespace Elsa.Studio.Models; + +/// A page the navigation leads to, with its href relative to the app's base address. +public record MenuPage(string Href, string Text, string? Icon); diff --git a/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs b/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs index 64d5481fdb..2b79c42be3 100644 --- a/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs +++ b/src/studio/framework/Elsa.Studio.Core/Services/DefaultFeatureService.cs @@ -22,6 +22,9 @@ public DefaultFeatureService(IEnumerable features, IRemoteFeatureProvi /// public event Action? Initialized; + /// + public bool IsInitialized { get; private set; } + /// public IEnumerable GetFeatures() { @@ -54,6 +57,7 @@ public async Task InitializeFeaturesAsync(CancellationToken cancellationToken = private void OnInitialized() { + IsInitialized = true; Initialized?.Invoke(); } } \ No newline at end of file diff --git a/src/studio/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs b/src/studio/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs index 873392791c..f214caa239 100644 --- a/src/studio/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs +++ b/src/studio/framework/Elsa.Studio.Core/Services/DefaultMenuService.cs @@ -1,3 +1,4 @@ +using Elsa.Studio.Authorization; using Elsa.Studio.Contracts; using Elsa.Studio.Models; @@ -8,35 +9,43 @@ public class DefaultMenuService : IMenuService { private readonly IEnumerable _menuProviders; private readonly IEnumerable _menuGroupProviders; + private readonly IPermissionService? _permissionService; /// /// Initializes a new instance of the class. /// - public DefaultMenuService(IEnumerable menuProviders, IEnumerable menuGroupProviders) + public DefaultMenuService(IEnumerable menuProviders, IEnumerable menuGroupProviders, IPermissionService? permissionService = null) { _menuProviders = menuProviders; _menuGroupProviders = menuGroupProviders; + _permissionService = permissionService; } /// public async ValueTask> GetMenuItemsAsync(CancellationToken cancellationToken = default) { var menu = new List(); - + foreach (var menuProvider in _menuProviders) { var menuItems = await menuProvider.GetMenuItemsAsync(cancellationToken); menu.AddRange(menuItems); } - return menu.OrderBy(x => x.Order).ToList(); + // No IPermissionService (Security not installed): do not filter. When the service is registered it + // fail-closes via GET /identity/me/permissions — IdentityPermissionService never returns Unknown. + var permissions = _permissionService != null + ? await _permissionService.GetPermissionsAsync(cancellationToken) + : UserPermissions.Unknown; + + return FilterByPermissions(menu, permissions).OrderBy(x => x.Order).ToList(); } /// public async ValueTask> GetMenuItemGroupsAsync(CancellationToken cancellationToken = default) { var groups = new List(); - + foreach (var menuGroupProvider in _menuGroupProviders) { var menuGroups = await menuGroupProvider.GetMenuGroupsAsync(cancellationToken); @@ -45,4 +54,32 @@ public async ValueTask> GetMenuItemGroupsAsync(Cancel return groups.DistinctBy(x => x.Name).OrderBy(x => x.Order).ToList(); } -} \ No newline at end of file + + /// + /// Removes the items the user lacks permissions for, and parents left without any visible children. Items are + /// copied rather than mutated, since providers may hand out shared instances. + /// + private static IEnumerable FilterByPermissions(IEnumerable items, UserPermissions permissions) + { + foreach (var item in items) + { + if (!permissions.HasAll(item.RequiredPermissions)) + { + continue; + } + + if (item.SubMenuItems.Count == 0) + { + yield return item; + continue; + } + + var subMenuItems = FilterByPermissions(item.SubMenuItems, permissions).ToList(); + + if (subMenuItems.Count > 0) + { + yield return item.WithSubMenuItems(subMenuItems); + } + } + } +} diff --git a/src/studio/framework/Elsa.Studio.Core/Services/PermissionRefreshSignal.cs b/src/studio/framework/Elsa.Studio.Core/Services/PermissionRefreshSignal.cs new file mode 100644 index 0000000000..38f4eab7c0 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Core/Services/PermissionRefreshSignal.cs @@ -0,0 +1,13 @@ +using Elsa.Studio.Contracts; + +namespace Elsa.Studio.Services; + +/// +public sealed class PermissionRefreshSignal : IPermissionRefreshSignal +{ + /// + public event Action? Raised; + + /// + public void Raise() => Raised?.Invoke(); +} diff --git a/src/studio/framework/Elsa.Studio.Shared/Components/AccessDenied.razor b/src/studio/framework/Elsa.Studio.Shared/Components/AccessDenied.razor new file mode 100644 index 0000000000..d2295ea11d --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Shared/Components/AccessDenied.razor @@ -0,0 +1,10 @@ +@using Elsa.Studio.Authorization + + + Your current sign-in does not include @string.Join(", ", MissingPermissions). + + +@code { + /// The permissions the user is missing. + [Parameter, EditorRequired] public IReadOnlyCollection MissingPermissions { get; set; } = []; +} diff --git a/src/studio/framework/Elsa.Studio.Shared/Components/AccessNotice.razor b/src/studio/framework/Elsa.Studio.Shared/Components/AccessNotice.razor new file mode 100644 index 0000000000..336b1d849f --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Shared/Components/AccessNotice.razor @@ -0,0 +1,20 @@ + + + @Title + + @Message + Ask an administrator to grant access, then sign in again. + + + + +@code { + /// The data-testid of the alert. + [Parameter, EditorRequired] public string TestId { get; set; } = default!; + + /// The heading. + [Parameter, EditorRequired] public string Title { get; set; } = default!; + + /// The explanation, shown before the closing advice to ask an administrator for access. + [Parameter, EditorRequired] public RenderFragment Message { get; set; } = default!; +} diff --git a/src/studio/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor b/src/studio/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor new file mode 100644 index 0000000000..a2c686efe3 --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Shared/Components/AppBar/UserMenu.razor @@ -0,0 +1,49 @@ +@using Microsoft.AspNetCore.Components.Authorization +@implements IDisposable +@inject AuthenticationStateProvider AuthenticationStateProvider + +@if (_currentState?.User.Identity?.IsAuthenticated is true) +{ + + @ChildContent + +} + +@code { + private AuthenticationState? _currentState; + + /// + /// The menu entries (e.g. sign-out actions) shown to the signed-in user. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + protected override async Task OnInitializedAsync() + { + AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged; + _currentState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + } + + /// + public void Dispose() => AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged; + + private async void OnAuthenticationStateChanged(Task state) + { + try + { + _currentState = await state; + await InvokeAsync(StateHasChanged); + } + catch + { + // An auth-state notification must not become an unobserved exception. + } + } +} diff --git a/src/studio/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor b/src/studio/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor new file mode 100644 index 0000000000..49f809010e --- /dev/null +++ b/src/studio/framework/Elsa.Studio.Shared/Components/BusyButtonContent.razor @@ -0,0 +1,21 @@ +@* Button content that swaps to a progress indicator and a busy label while an operation is in flight. *@ +@if (Busy) +{ +