diff --git a/.agents/rules/frontend/dashboard.md b/.agents/rules/frontend/dashboard.md index d0cb78a032..a680ed67c4 100644 --- a/.agents/rules/frontend/dashboard.md +++ b/.agents/rules/frontend/dashboard.md @@ -10,9 +10,9 @@ Tenant-facing application. Read `frontend/shared.md` first; this file is only th The dashboard does **not** depend on react-hook-form or zod. Use controlled inputs + local state. Don't add those deps to match admin. -## Permissions — straight from the JWT +## Permissions — fetched, not in the JWT -`auth-context.tsx` reads `claims.permissions` off the decoded JWT — no separate fetch, no `permissionsHydrated`, no permissions cache key. `ProtectedRoute` is **auth-only** (no permission gating). Don't add `RouteGuard`-style gating here. +The JWT carries **only role names**. `auth-context.tsx` fetches the effective permission list from `GET /api/v1/identity/permissions` (`getMyPermissions()` in `src/api/identity.ts`), caches it in `tokenStore` under `fsh.dashboard.permissions`, and exposes a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight (re-fetched on login/impersonation swaps; `refreshPermissions()` for role changes). Nav items are gated via `perm`/`anyPerm` in `src/components/layout/nav-data.ts`. `ProtectedRoute` is still **auth-only** (no per-route permission gating) — don't add `RouteGuard`-style gating here. ## Routing & realtime/SSE diff --git a/.agents/skills/add-permission/SKILL.md b/.agents/skills/add-permission/SKILL.md index 8f5e637ce1..a9c28c7701 100644 --- a/.agents/skills/add-permission/SKILL.md +++ b/.agents/skills/add-permission/SKILL.md @@ -61,7 +61,7 @@ So `RouteGuard` passes on first paint, add the new permission to the test seed s ## Dashboard -No mirror, no `RouteGuard`. The permission rides in the JWT (`claims.permissions`) and the server enforces it; a missing permission yields a 403 the UI surfaces. Nothing to add client-side beyond consuming the gated endpoint. +No mirror, no `RouteGuard`. The JWT carries only role names — the app fetches the permission list from `GET /api/v1/identity/permissions` at hydration and the server enforces access; a missing permission yields a 403 the UI surfaces. Routes aren't permission-gated; to hide a nav entry, set `perm`/`anyPerm` on the item in `src/components/layout/nav-data.ts`. Permission-gated specs mock `GET /identity/permissions` with the grants they need (shell mocks stub it to `[]`). ## Checklist diff --git a/.agents/skills/add-react-page/SKILL.md b/.agents/skills/add-react-page/SKILL.md index 4dd83d1837..af29b601db 100644 --- a/.agents/skills/add-react-page/SKILL.md +++ b/.agents/skills/add-react-page/SKILL.md @@ -17,7 +17,7 @@ The frontend slice. Read `.agents/rules/frontend/shared.md` plus the app file (` | Forms | **react-hook-form + zod** | **hand-rolled** controlled inputs (no RHF/zod) | | List + create | separate routed pages (`list.tsx`, `create.tsx`) | one file with `` editors | | Route wrapper | `` | `withSuspense()` (no permission gate) | -| Permissions | mirror in `src/lib/permissions.ts` | none — JWT claims + server 403 | +| Permissions | mirror in `src/lib/permissions.ts` | fetched from `GET /identity/permissions` (not JWT); nav gating via `perm`/`anyPerm` in `nav-data.ts`; no route guard — server 403 backstops | Shared everywhere: types are **hand-written** (no codegen); `apiFetch` from `@/lib/api-client`; `cn()` from `@/lib/cn`; `env.apiBase` from runtime `/config.json`; CVA `components/ui` + `components/list` primitives; Tailwind v4 CSS-first (tokens in `src/styles/globals.css`); `toast` from `sonner`; pages are **named exports**; `placeholderData: keepPreviousData` (v5). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5e690f421b..82ce10f2c2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, - "postCreateCommand": "dotnet workload install aspire && dotnet restore src/FSH.Starter.slnx", + "postCreateCommand": "dotnet restore src/FSH.Starter.slnx", "forwardPorts": [5030, 7030, 15888], "customizations": { "vscode": { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71155e33c3..63a8ff8318 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Thanks for helping out. The conventions below keep PRs reviewable. ## Dev setup -Prerequisites: .NET 10 SDK, Docker, Node.js 22+. +Prerequisites: .NET 10 SDK, Docker, Node.js 20+. ```bash dotnet build src/FSH.Starter.slnx diff --git a/README-template.md b/README-template.md index 1d91f9d2f2..d97f32625d 100644 --- a/README-template.md +++ b/README-template.md @@ -12,7 +12,6 @@ the shared code lives in `src/BuildingBlocks` and is yours to change. - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) - [Node.js 20+](https://nodejs.org) — for the React apps - [Docker](https://www.docker.com/) — Postgres, Redis, MinIO (orchestrated by Aspire) -- .NET Aspire workload: `dotnet workload install aspire` ## Quick start diff --git a/clients/dashboard/tests/helpers/auth-seed.ts b/clients/dashboard/tests/helpers/auth-seed.ts index dbe02e852a..3eaf4bd166 100644 --- a/clients/dashboard/tests/helpers/auth-seed.ts +++ b/clients/dashboard/tests/helpers/auth-seed.ts @@ -7,8 +7,11 @@ import type { Page } from "@playwright/test"; * We populate the same localStorage keys the runtime tokenStore writes * (see clients/dashboard/src/auth/token-store.ts). The token value is * a JWT-shaped string that decodes to the supplied user — useAuth's - * decoder reads sub/email/given_name/family_name/tenant/permissions - * out of the payload. + * decoder reads sub/email/given_name/family_name/tenant out of the + * payload. Permissions are NOT read from the JWT: at runtime the app + * fetches them from GET /api/v1/identity/permissions (installShellMocks + * stubs that route to []; permission-gated specs re-mock it with the + * grants they need — see tests/system/trash.spec.ts). */ export type SeededUser = { sub: string; @@ -16,6 +19,11 @@ export type SeededUser = { firstName: string; lastName: string; tenant: string; + /** + * Inert — written into the fake JWT payload but ignored by the app. + * To grant permissions in a spec, mock GET /identity/permissions + * instead. + */ permissions?: string[]; }; @@ -36,7 +44,7 @@ function fakeJwt(payload: Record): string { export async function seedAuthedSession(page: Page, user: SeededUser) { // Build the JWT-shaped payload. Claim names match what useAuth's decoder - // looks for in the runtime path. + // looks for in the runtime path (permissions excepted — inert, see SeededUser). const payload = { sub: user.sub, email: user.email, diff --git a/src/Host/FSH.Starter.DbMigrator/MigratorCommand.cs b/src/Host/FSH.Starter.DbMigrator/MigratorCommand.cs index 4d9dfe437e..b1d3c0d807 100644 --- a/src/Host/FSH.Starter.DbMigrator/MigratorCommand.cs +++ b/src/Host/FSH.Starter.DbMigrator/MigratorCommand.cs @@ -66,7 +66,7 @@ apply Apply pending migrations (default). Use --seed to also run SeedA seed Run only the SeedAsync step per tenant. seed-demo Provision the demo tenants (acme, globex) with users, catalog, tickets, and chat. Dev-only — refuses to run unless - ASPNETCORE_ENVIRONMENT=Development. + DOTNET_ENVIRONMENT=Development. list-pending Print pending migrations without applying anything. Options: diff --git a/src/Host/FSH.Starter.DbMigrator/Program.cs b/src/Host/FSH.Starter.DbMigrator/Program.cs index 727458a26e..8cc2cee3f0 100644 --- a/src/Host/FSH.Starter.DbMigrator/Program.cs +++ b/src/Host/FSH.Starter.DbMigrator/Program.cs @@ -291,7 +291,7 @@ await Console.Out.WriteLineAsync( if (!env.IsDevelopment()) { await Console.Error.WriteLineAsync( - $"[demo-seed] REFUSING to run — ASPNETCORE_ENVIRONMENT is '{env.EnvironmentName}'. " + $"[demo-seed] REFUSING to run — DOTNET_ENVIRONMENT is '{env.EnvironmentName}'. " + "seed-demo is dev-only by design.") .ConfigureAwait(false); return 1; diff --git a/src/Host/FSH.Starter.DbMigrator/README.md b/src/Host/FSH.Starter.DbMigrator/README.md index 9395d51d4e..8919b88738 100644 --- a/src/Host/FSH.Starter.DbMigrator/README.md +++ b/src/Host/FSH.Starter.DbMigrator/README.md @@ -43,7 +43,7 @@ dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed # Dev only — provision the demo tenants (acme, globex) with users, # custom roles, sample catalog, tickets, and chat. Hard-refuses outside # Development. Idempotent: safe to re-run. -ASPNETCORE_ENVIRONMENT=Development \ +DOTNET_ENVIRONMENT=Development \ dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo ``` diff --git a/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs b/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs index 3a9aa2b1e2..1be976d900 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs @@ -70,11 +70,25 @@ private static async ValueTask LoadPermissionsAsync(FactoryState var userRoles = await s.UserManager.GetRolesAsync(user).ConfigureAwait(false); - var roleIds = await s.RoleManager.Roles + var directRoleIds = await s.RoleManager.Roles .Where(r => userRoles.Contains(r.Name!)) .Select(r => r.Id) .ToListAsync(ct).ConfigureAwait(false); + // Group-derived roles confer permissions too — the JWT already unions them + // (IdentityService.AddRoleClaimsAsync) and every group mutation invalidates this + // cache entry, so the effective set must include roles reachable via UserGroups. + var groupRoleIds = await s.Db.GroupRoles + .Where(gr => s.Db.UserGroups + .Where(ug => ug.UserId == s.UserId) + .Select(ug => ug.GroupId) + .Contains(gr.GroupId)) + .Select(gr => gr.RoleId) + .Distinct() + .ToListAsync(ct).ConfigureAwait(false); + + var roleIds = directRoleIds.Union(groupRoleIds, StringComparer.Ordinal).ToList(); + if (roleIds.Count == 0) { return PermissionSet.Empty; diff --git a/src/Tests/Architecture.Tests/AuthorizationMetadataTests.cs b/src/Tests/Architecture.Tests/AuthorizationMetadataTests.cs new file mode 100644 index 0000000000..f3b0a6493d --- /dev/null +++ b/src/Tests/Architecture.Tests/AuthorizationMetadataTests.cs @@ -0,0 +1,96 @@ +using FSH.Framework.Shared.Identity.Authorization; +using Shouldly; +using System.Reflection; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Guards the permission-metadata contract between RequiredPermissionAttribute and +/// RequiredPermissionAuthorizationHandler. The handler resolves endpoint permissions via +/// ; if a duplicate RequiredPermissionAttribute +/// appears in another assembly without implementing that interface, endpoints decorated with it +/// carry no recognizable metadata and every .RequirePermission() gate silently fails open. +/// +public class AuthorizationMetadataTests +{ + private const string AttributeName = "RequiredPermissionAttribute"; + private const string ExpectedNamespace = "FSH.Framework.Shared.Identity.Authorization"; + + [Fact] + public void RequiredPermissionAttribute_Should_Exist_Exactly_Once_Across_All_FSH_Assemblies() + { + var matches = GetAllFshAssemblies() + .SelectMany(GetLoadableTypes) + .Where(t => string.Equals(t.Name, AttributeName, StringComparison.Ordinal)) + .ToArray(); + + matches.ShouldNotBeEmpty( + $"{AttributeName} was not found in any FSH assembly. " + + "The permission authorization pipeline depends on it."); + + matches.Length.ShouldBe(1, + $"Exactly one {AttributeName} must exist across all FSH assemblies. " + + "A duplicate that does not implement IRequiredPermissionMetadata silently disables " + + $"every .RequirePermission() gate. Found: {string.Join(", ", matches.Select(t => $"{t.FullName} ({t.Assembly.GetName().Name})"))}"); + + matches[0].Namespace.ShouldBe(ExpectedNamespace, + $"{AttributeName} must live in {ExpectedNamespace}, where " + + "RequiredPermissionAuthorizationHandler resolves its metadata from."); + } + + [Fact] + public void RequiredPermissionAttribute_Should_Implement_IRequiredPermissionMetadata() + { + var attributeType = typeof(RequiredPermissionAttribute); + + typeof(IRequiredPermissionMetadata).IsAssignableFrom(attributeType).ShouldBeTrue( + $"{attributeType.FullName} must implement IRequiredPermissionMetadata. " + + "RequiredPermissionAuthorizationHandler discovers endpoint permissions through that " + + "interface; without it, every .RequirePermission() gate silently fails open."); + } + + /// + /// Loads every FSH.* assembly deployed alongside the tests so the duplicate sweep covers + /// BuildingBlocks, all modules (including Contracts), and host assemblies — not just the + /// runtime module assemblies that ModuleAssemblyDiscovery returns. + /// + private static Assembly[] GetAllFshAssemblies() + { + string baseDir = AppContext.BaseDirectory; + + var assemblies = new List(); + + foreach (var file in Directory.GetFiles(baseDir, "FSH.*.dll")) + { + try + { + var assemblyName = AssemblyName.GetAssemblyName(file); + assemblies.Add(Assembly.Load(assemblyName)); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception) + { + // Skip if not a valid .NET assembly or other load error + } +#pragma warning restore CA1031 + } + + assemblies.ShouldNotBeEmpty( + "No FSH.* assemblies were found in the test output directory; the duplicate sweep would be a no-op."); + + return [.. assemblies]; + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } +} diff --git a/src/Tests/Integration.Tests/Tests/Groups/GroupRolePermissionTests.cs b/src/Tests/Integration.Tests/Tests/Groups/GroupRolePermissionTests.cs new file mode 100644 index 0000000000..253fe06a06 --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Groups/GroupRolePermissionTests.cs @@ -0,0 +1,175 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.Authorization; +using FSH.Modules.Identity.Domain; +using Integration.Tests.Infrastructure; +using Integration.Tests.Infrastructure.Extensions; +using Microsoft.AspNetCore.Identity; + +namespace Integration.Tests.Tests.Groups; + +/// +/// Proves group-derived roles confer PERMISSIONS, not just JWT role claims. +/// IdentityService.AddRoleClaimsAsync unions direct roles with +/// IGroupRoleService.GetUserGroupRolesAsync when minting tokens, and every group +/// mutation handler invalidates the permission cache — so UserPermissionService +/// must resolve the same union, or a user whose only role comes via a group fails every +/// .RequirePermission() gate despite their JWT showing the role. +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class GroupRolePermissionTests +{ + private const string ProbePermission = IdentityPermissions.Groups.Create; + + private readonly FshWebApplicationFactory _factory; + private readonly AuthHelper _auth; + + public GroupRolePermissionTests(FshWebApplicationFactory factory) + { + _factory = factory; + _auth = new AuthHelper(factory); + } + + [Fact] + public async Task GetOwnPermissions_Should_IncludeGroupRolePermissions_When_UserHasNoDirectRoles() + { + // Arrange — custom role with the probe permission, attached to a GROUP (never to the user). + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + + var role = await CreateRoleWithPermissionAsync(adminClient, $"GrpPermRole-{uniqueId}", ProbePermission); + var groupId = await CreateGroupWithRoleAsync(adminClient, $"PermGroup-{uniqueId}", role.Id); + + var (email, password, userId) = await CreateActiveUserAsync($"grpperm-{uniqueId}"); + await AddUserToGroupAsync(adminClient, groupId, userId); + + using var userClient = await _auth.CreateAuthenticatedClientAsync(email, password); + + // Act — read the user's own effective permissions (auth-only endpoint, backed by UserPermissionService). + var response = await userClient.GetAsync($"{TestConstants.IdentityBasePath}/permissions"); + response.StatusCode.ShouldBe(HttpStatusCode.OK, + $"Get current-user permissions failed: {await response.Content.ReadAsStringAsync()}"); + var permissions = await response.DeserializeAsync>(); + + // Assert — the group-derived role's permission must be in the effective set. + permissions.ShouldContain(ProbePermission, + "UserPermissionService only resolves DIRECT role assignments: a role granted via group membership " + + "confers no permissions, even though the JWT carries the role claim."); + } + + [Fact] + public async Task PermissionGate_Should_Open_When_UsersOnlyRoleComesViaGroup() + { + // Arrange — same setup, but probe through a real .RequirePermission() gate + // (POST /groups requires Groups.Create), exercising the authorization handler path. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + + var role = await CreateRoleWithPermissionAsync(adminClient, $"GrpGateRole-{uniqueId}", ProbePermission); + var groupId = await CreateGroupWithRoleAsync(adminClient, $"GateGroup-{uniqueId}", role.Id); + + var (email, password, userId) = await CreateActiveUserAsync($"grpgate-{uniqueId}"); + await AddUserToGroupAsync(adminClient, groupId, userId); + + using var userClient = await _auth.CreateAuthenticatedClientAsync(email, password); + + // Act — hit the gated endpoint with the group-conferred permission. + var response = await userClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/groups", new + { + name = $"grp-by-member-{uniqueId}", + description = "created via group-derived permission", + isDefault = false, + roleIds = new List() + }); + + // Assert — the gate must honor the group-derived role. + response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden, + "RequiredPermissionAuthorizationHandler denied a user whose group membership grants a role holding the required permission."); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + } + + // ─── helpers ───────────────────────────────────────────────────── + + private static async Task CreateRoleWithPermissionAsync(HttpClient adminClient, string name, string permission) + { + var createResponse = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/roles", new + { + id = string.Empty, + name, + description = "group-role permission test role" + }); + var role = await createResponse.DeserializeAsync(); + + var permResponse = await adminClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/{role.Id}/permissions", new + { + roleId = role.Id, + permissions = new[] { permission } + }); + permResponse.StatusCode.ShouldBe(HttpStatusCode.OK, + $"Set role permissions failed: {await permResponse.Content.ReadAsStringAsync()}"); + + return role; + } + + private static async Task CreateGroupWithRoleAsync(HttpClient adminClient, string name, string roleId) + { + var response = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/groups", new + { + name, + description = "group-role permission test group", + isDefault = false, + roleIds = new List { roleId } + }); + response.StatusCode.ShouldBe(HttpStatusCode.Created, + $"Create group failed: {await response.Content.ReadAsStringAsync()}"); + var group = await response.DeserializeAsync(); + return group.Id; + } + + private static async Task AddUserToGroupAsync(HttpClient adminClient, Guid groupId, string userId) + { + var response = await adminClient.PostAsJsonAsync( + $"{TestConstants.IdentityBasePath}/groups/{groupId}/members", + new { userIds = new[] { userId } }); + response.StatusCode.ShouldBe(HttpStatusCode.OK, + $"Add user to group failed: {await response.Content.ReadAsStringAsync()}"); + } + + /// + /// Seeds a confirmed + active user with NO role assignments. The Finbuckle tenant + /// context is set INLINE because it is AsyncLocal — setting it in an awaited helper + /// would lose it and the tenant query filter would throw. + /// + private async Task<(string Email, string Password, string UserId)> CreateActiveUserAsync(string handle) + { + const string password = TestConstants.DefaultPassword; + var email = $"{handle}@example.com"; + + using var scope = _factory.Services.CreateScope(); + + var tenant = await scope.ServiceProvider + .GetRequiredService>() + .GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new FshUser + { + FirstName = "GroupRole", + LastName = "Probe", + Email = email, + UserName = handle, + EmailConfirmed = true, + IsActive = true, + }; + + var result = await userManager.CreateAsync(user, password); + result.Succeeded.ShouldBeTrue( + $"Seeding active user failed: {string.Join(", ", result.Errors.Select(e => e.Description))}"); + + return (email, password, user.Id); + } +} diff --git a/src/Tools/CLI/Program.cs b/src/Tools/CLI/Program.cs index 9ba6758d74..02b84f90f7 100644 --- a/src/Tools/CLI/Program.cs +++ b/src/Tools/CLI/Program.cs @@ -18,7 +18,7 @@ config.AddCommand("new") .WithDescription("Create a new FullStackHero .NET project.") .WithExample("new", "MyApp") - .WithExample("new", "MyApp", "--no-aspire", "--no-git"); + .WithExample("new", "MyApp", "--no-aspire", "--no-frontend"); config.AddCommand("doctor") .WithDescription("Check your development environment for required tools.");