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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using SimpleModule.Core.Extensions;

namespace SimpleModule.Core.Authorization;

Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions framework/SimpleModule.Core/Authorization/WellKnownClaims.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SimpleModule.Core.Authorization;

public static class WellKnownClaims
{
public const string Permission = "permission";
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,30 @@ public static class ClaimsPrincipalExtensions
{
return principal.IsInRole(WellKnownRoles.Admin) ? null : principal.GetUserId();
}

/// <summary>
/// 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 <paramref name="permission"/>.
/// </summary>
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;
}
}
7 changes: 7 additions & 0 deletions framework/SimpleModule.Core/Menu/MenuItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ public sealed class MenuItem
/// An empty list means visible to all authenticated users.
/// </summary>
public IReadOnlyList<string> Roles { get; init; } = [];

/// <summary>
/// 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.
/// </summary>
public string? RequiredPermission { get; init; }
}
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -45,7 +46,13 @@ IReadOnlyList<MenuItem> 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();
}

Expand Down
82 changes: 47 additions & 35 deletions modules/Dashboard/src/SimpleModule.Dashboard/Pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,21 @@ interface HomeProps {

export default function Home({ isAuthenticated, displayName, isDevelopment }: HomeProps) {
return isAuthenticated ? (
<DashboardView displayName={displayName} />
<DashboardView displayName={displayName} isDevelopment={isDevelopment} />
) : (
<LandingView isDevelopment={isDevelopment} />
);
}

// --- Dashboard View ---

function DashboardView({ displayName }: { displayName: string }) {
function DashboardView({
displayName,
isDevelopment,
}: {
displayName: string;
isDevelopment: boolean;
}) {
const { t } = useTranslation('Dashboard');
return (
<PageShell
Expand Down Expand Up @@ -73,32 +79,34 @@ function DashboardView({ displayName }: { displayName: string }) {
</CardContent>
</Card>
</a>
<a href="/swagger" className="no-underline">
<Card className="h-full group">
<CardContent>
<div className="flex items-center gap-3 mb-3">
<span className="w-9 h-9 rounded-xl flex items-center justify-center text-accent bg-success-bg">
<svg
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</span>
<span className="text-sm font-semibold text-text group-hover:text-primary transition-colors">
{t(DashboardKeys.Home.ApiDocsCardTitle)}
</span>
</div>
<p className="text-xs text-text-muted">
{t(DashboardKeys.Home.ApiDocsCardDescription)}
</p>
</CardContent>
</Card>
</a>
{isDevelopment && (
<a href="/swagger" className="no-underline">
<Card className="h-full group">
<CardContent>
<div className="flex items-center gap-3 mb-3">
<span className="w-9 h-9 rounded-xl flex items-center justify-center text-accent bg-success-bg">
<svg
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</span>
<span className="text-sm font-semibold text-text group-hover:text-primary transition-colors">
{t(DashboardKeys.Home.ApiDocsCardTitle)}
</span>
</div>
<p className="text-xs text-text-muted">
{t(DashboardKeys.Home.ApiDocsCardDescription)}
</p>
</CardContent>
</Card>
</a>
)}
<a href="/health/live" className="no-underline">
<Card className="h-full group">
<CardContent>
Expand Down Expand Up @@ -569,13 +577,17 @@ function LandingView({ isDevelopment }: { isDevelopment: boolean }) {
)}

<div className="flex gap-5 justify-center mt-8 text-sm">
<a
href="/swagger"
className="text-text-muted no-underline hover:text-primary transition-colors"
>
{t(DashboardKeys.Home.LandingApiDocs)}
</a>
<span className="text-border">&middot;</span>
{isDevelopment && (
<>
<a
href="/swagger"
className="text-text-muted no-underline hover:text-primary transition-colors"
>
{t(DashboardKeys.Home.LandingApiDocs)}
</a>
<span className="text-border">&middot;</span>
</>
)}
<a
href="/health/live"
className="text-text-muted no-underline hover:text-primary transition-colors"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public void ConfigureMenu(IMenuBuilder menus)
"""<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg>""",
Order = 55,
Section = MenuSection.AppSidebar,
RequiredPermission = DatasetsPermissions.View,
}
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public void ConfigureMenu(IMenuBuilder menus)
"""<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>""",
Order = 50,
Section = MenuSection.AppSidebar,
RequiredPermission = FileStoragePermissions.View,
}
);
}
Expand Down
8 changes: 8 additions & 0 deletions template/SimpleModule.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading