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); + } +}