From c9563f11434b7c327e72fd2e07ac440adaa7a8c6 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 14:00:33 +0200 Subject: [PATCH] Fix production network issues on app.simplemodule.dev Live probing of the deployed site surfaced three user-visible 404s / dead links: - Dashboard landing cards and footer advertise /swagger, which is only mounted in Development. Thread isDevelopment through DashboardView and gate both links. - /favicon.ico 404s on every page load because wwwroot only ships favicon.svg. Add a host-level route that serves the SVG at the .ico path with the correct content-type. - Sidebar shows Files and Datasets to authenticated users without the required permission, routing them into AccessDenied on click. MenuItem.Roles was the only filter; the view endpoints use .RequirePermission(). Add MenuItem.RequiredPermission, a reusable ClaimsPrincipal.HasPermission extension (admin bypass + exact + wildcard via existing PermissionMatcher), filter menus on it in InertiaLayoutDataMiddleware, and set the permission on the two offending menu entries. PermissionAuthorizationHandler now delegates to HasPermission so the logic lives in one place, and the "permission" claim type is lifted to WellKnownClaims.Permission. --- .../PermissionAuthorizationHandler.cs | 25 +----- .../Authorization/WellKnownClaims.cs | 6 ++ .../Extensions/ClaimsPrincipalExtensions.cs | 26 ++++++ framework/SimpleModule.Core/Menu/MenuItem.cs | 7 ++ .../Middleware/InertiaLayoutDataMiddleware.cs | 9 +- .../src/SimpleModule.Dashboard/Pages/Home.tsx | 82 +++++++++++-------- .../SimpleModule.Datasets/DatasetsModule.cs | 1 + .../FileStorageModule.cs | 1 + template/SimpleModule.Host/Program.cs | 8 ++ .../ClaimsPrincipalExtensionsTests.cs | 63 ++++++++++++++ 10 files changed, 169 insertions(+), 59 deletions(-) create mode 100644 framework/SimpleModule.Core/Authorization/WellKnownClaims.cs create mode 100644 tests/SimpleModule.Core.Tests/Extensions/ClaimsPrincipalExtensionsTests.cs diff --git a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs index c4f5c660..4aee2b4d 100644 --- a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs +++ b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using SimpleModule.Core.Extensions; namespace SimpleModule.Core.Authorization; @@ -10,31 +11,9 @@ protected override Task HandleRequirementAsync( PermissionRequirement requirement ) { - // Admin role bypasses all permission checks - if (context.User.IsInRole(WellKnownRoles.Admin)) + if (context.User.HasPermission(requirement.Permission)) { context.Succeed(requirement); - return Task.CompletedTask; - } - - // Exact match (fast path) - if (context.User.HasClaim("permission", requirement.Permission)) - { - context.Succeed(requirement); - return Task.CompletedTask; - } - - // Wildcard match: check all permission claims for wildcard patterns - foreach (var claim in context.User.Claims) - { - if ( - claim.Type == "permission" - && PermissionMatcher.IsMatch(claim.Value, requirement.Permission) - ) - { - context.Succeed(requirement); - return Task.CompletedTask; - } } return Task.CompletedTask; diff --git a/framework/SimpleModule.Core/Authorization/WellKnownClaims.cs b/framework/SimpleModule.Core/Authorization/WellKnownClaims.cs new file mode 100644 index 00000000..570963c8 --- /dev/null +++ b/framework/SimpleModule.Core/Authorization/WellKnownClaims.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Core.Authorization; + +public static class WellKnownClaims +{ + public const string Permission = "permission"; +} diff --git a/framework/SimpleModule.Core/Extensions/ClaimsPrincipalExtensions.cs b/framework/SimpleModule.Core/Extensions/ClaimsPrincipalExtensions.cs index 6dadd89b..39cd3704 100644 --- a/framework/SimpleModule.Core/Extensions/ClaimsPrincipalExtensions.cs +++ b/framework/SimpleModule.Core/Extensions/ClaimsPrincipalExtensions.cs @@ -21,4 +21,30 @@ public static class ClaimsPrincipalExtensions { return principal.IsInRole(WellKnownRoles.Admin) ? null : principal.GetUserId(); } + + /// + /// Returns true if the principal satisfies the given permission requirement. + /// Admin role bypasses the check; otherwise the user's "permission" claims are + /// matched (exact and wildcard) against . + /// + public static bool HasPermission(this ClaimsPrincipal principal, string permission) + { + if (principal.IsInRole(WellKnownRoles.Admin)) + { + return true; + } + + foreach (var claim in principal.Claims) + { + if ( + claim.Type == WellKnownClaims.Permission + && PermissionMatcher.IsMatch(claim.Value, permission) + ) + { + return true; + } + } + + return false; + } } diff --git a/framework/SimpleModule.Core/Menu/MenuItem.cs b/framework/SimpleModule.Core/Menu/MenuItem.cs index 4c72e097..1b32c6e7 100644 --- a/framework/SimpleModule.Core/Menu/MenuItem.cs +++ b/framework/SimpleModule.Core/Menu/MenuItem.cs @@ -22,4 +22,11 @@ public sealed class MenuItem /// An empty list means visible to all authenticated users. /// public IReadOnlyList Roles { get; init; } = []; + + /// + /// When set, this menu item is only visible to users whose permission claims + /// satisfy the requirement (supports wildcards via PermissionMatcher). + /// Admin role bypasses this check. Null means no permission gating. + /// + public string? RequiredPermission { get; init; } } diff --git a/framework/SimpleModule.Hosting/Middleware/InertiaLayoutDataMiddleware.cs b/framework/SimpleModule.Hosting/Middleware/InertiaLayoutDataMiddleware.cs index b4d8ef9f..f98dc66b 100644 --- a/framework/SimpleModule.Hosting/Middleware/InertiaLayoutDataMiddleware.cs +++ b/framework/SimpleModule.Hosting/Middleware/InertiaLayoutDataMiddleware.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Extensions; using SimpleModule.Core.Inertia; using SimpleModule.Core.Menu; @@ -45,7 +46,13 @@ IReadOnlyList Filter(MenuSection section) } return items - .Where(m => m.Roles.Count == 0 || m.Roles.Any(r => user.IsInRole(r))) + .Where(m => + (m.Roles.Count == 0 || m.Roles.Any(r => user.IsInRole(r))) + && ( + m.RequiredPermission is null + || user.HasPermission(m.RequiredPermission) + ) + ) .ToList(); } diff --git a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx index 135636e8..d14e43df 100644 --- a/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx +++ b/modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx @@ -30,7 +30,7 @@ interface HomeProps { export default function Home({ isAuthenticated, displayName, isDevelopment }: HomeProps) { return isAuthenticated ? ( - + ) : ( ); @@ -38,7 +38,13 @@ export default function Home({ isAuthenticated, displayName, isDevelopment }: Ho // --- Dashboard View --- -function DashboardView({ displayName }: { displayName: string }) { +function DashboardView({ + displayName, + isDevelopment, +}: { + displayName: string; + isDevelopment: boolean; +}) { const { t } = useTranslation('Dashboard'); return ( - - - -
- - - - - {t(DashboardKeys.Home.ApiDocsCardTitle)} - -
-

- {t(DashboardKeys.Home.ApiDocsCardDescription)} -

-
-
-
+ {isDevelopment && ( + + + +
+ + + + + {t(DashboardKeys.Home.ApiDocsCardTitle)} + +
+

+ {t(DashboardKeys.Home.ApiDocsCardDescription)} +

+
+
+
+ )} @@ -569,13 +577,17 @@ function LandingView({ isDevelopment }: { isDevelopment: boolean }) { )}
- - {t(DashboardKeys.Home.LandingApiDocs)} - - · + {isDevelopment && ( + <> + + {t(DashboardKeys.Home.LandingApiDocs)} + + · + + )} """, Order = 55, Section = MenuSection.AppSidebar, + RequiredPermission = DatasetsPermissions.View, } ); } diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs index 5e1b259f..5051435d 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs @@ -37,6 +37,7 @@ public void ConfigureMenu(IMenuBuilder menus) """""", Order = 50, Section = MenuSection.AppSidebar, + RequiredPermission = FileStoragePermissions.View, } ); } diff --git a/template/SimpleModule.Host/Program.cs b/template/SimpleModule.Host/Program.cs index 0dace48a..5195147e 100644 --- a/template/SimpleModule.Host/Program.cs +++ b/template/SimpleModule.Host/Program.cs @@ -20,4 +20,12 @@ await app.UseSimpleModule(); app.MapDefaultEndpoints(); +app.MapGet( + "/favicon.ico", + (IWebHostEnvironment env) => + Results.File(Path.Combine(env.WebRootPath, "favicon.svg"), "image/svg+xml") + ) + .ExcludeFromDescription() + .AllowAnonymous(); + await app.RunAsync(); diff --git a/tests/SimpleModule.Core.Tests/Extensions/ClaimsPrincipalExtensionsTests.cs b/tests/SimpleModule.Core.Tests/Extensions/ClaimsPrincipalExtensionsTests.cs new file mode 100644 index 00000000..9ba7ec45 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Extensions/ClaimsPrincipalExtensionsTests.cs @@ -0,0 +1,63 @@ +using System.Security.Claims; +using FluentAssertions; +using SimpleModule.Core.Extensions; + +namespace SimpleModule.Core.Tests.Extensions; + +public class ClaimsPrincipalExtensionsTests +{ + [Fact] + public void HasPermission_AdminRole_ReturnsTrue() + { + var user = CreateUser(new Claim(ClaimTypes.Role, "Admin")); + + user.HasPermission("Anything.Goes").Should().BeTrue(); + } + + [Fact] + public void HasPermission_ExactClaimMatch_ReturnsTrue() + { + var user = CreateUser(new Claim("permission", "Products.View")); + + user.HasPermission("Products.View").Should().BeTrue(); + } + + [Fact] + public void HasPermission_WildcardClaimMatch_ReturnsTrue() + { + var user = CreateUser(new Claim("permission", "Products.*")); + + user.HasPermission("Products.View").Should().BeTrue(); + user.HasPermission("Products.Delete").Should().BeTrue(); + } + + [Fact] + public void HasPermission_GlobalWildcardClaim_ReturnsTrue() + { + var user = CreateUser(new Claim("permission", "*")); + + user.HasPermission("Foo").Should().BeTrue(); + } + + [Fact] + public void HasPermission_NoMatchingClaim_ReturnsFalse() + { + var user = CreateUser(new Claim("permission", "Orders.View")); + + user.HasPermission("Products.View").Should().BeFalse(); + } + + [Fact] + public void HasPermission_EmptyPrincipal_ReturnsFalse() + { + var user = new ClaimsPrincipal(new ClaimsIdentity()); + + user.HasPermission("Anything").Should().BeFalse(); + } + + private static ClaimsPrincipal CreateUser(params Claim[] claims) + { + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } +}