From 3936efa1f54f4d8e6829b0fd6d09bde11f97331c Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 12 Jun 2026 17:19:43 +0530 Subject: [PATCH 1/2] fix(clients): permission-gate every nav surface to mirror server authorization A tenant user whose role lacks a module permission could still see and open nav entries for that module, landing on a guaranteed 403 (e.g. the dashboard Files page showing "FAILURE - ForbiddenException" for the demo Manager role). Dashboard: - nav-data.ts: gate Chat, My Files, Subscription, Invoices, Products, Brands, Categories, and Tickets on the same permission their page's primary endpoint enforces. Live activity (auth-only SSE) and Health (anonymous probe) stay ungated. - command palette: the Navigate and Create actions were a second, fully ungated nav surface. Added perm/anyPerm gates mirroring nav-data.ts and the create endpoints' permissions; empty groups are dropped. Admin: - Webhooks nav entry and routes had no gate even though the API now requires Permissions.Webhooks.View; added the nav perm + RouteGuard. - Mirrored WebhooksPermissions into lib/permissions.ts and added the missing Webhooks group to PERMISSION_CATALOG so the Role editor can actually grant those permissions. DemoSeeder: - The acme Manager role's catalog claims used permission names that do not exist ("Permissions.Brands.View" vs the real "Permissions.Catalog.Brands.View"), silently granting nothing. Role permission lists now reference the module contracts constants so they cannot drift again. Re-run the migrator seed-demo verb to heal existing databases. Co-Authored-By: Claude Fable 5 --- .../admin/src/components/layout/nav-items.ts | 3 + clients/admin/src/lib/permissions.ts | 19 ++++++ clients/admin/src/routes.tsx | 22 +++++- clients/admin/tests/helpers/shell-mocks.ts | 4 ++ .../command-palette-dialog.tsx | 49 +++++++++++++- .../src/components/layout/nav-data.ts | 21 +++--- .../DemoSeed/DemoSeeder.cs | 67 ++++++++++--------- 7 files changed, 141 insertions(+), 44 deletions(-) diff --git a/clients/admin/src/components/layout/nav-items.ts b/clients/admin/src/components/layout/nav-items.ts index c31223454c..d2cc4626b4 100644 --- a/clients/admin/src/components/layout/nav-items.ts +++ b/clients/admin/src/components/layout/nav-items.ts @@ -16,6 +16,7 @@ import { BillingPermissions, IdentityPermissions, MultitenancyPermissions, + WebhooksPermissions, } from "@/lib/permissions"; /** A single nav destination — label, route, icon, optional perm guard. */ @@ -101,6 +102,7 @@ export const sections: NavSection[] = [ to: "/webhooks", label: "Webhooks", icon: Webhook, + perms: [WebhooksPermissions.Subscriptions.View], }, { to: "/audits", @@ -208,6 +210,7 @@ export const NAV_ITEMS: NavItem[] = [ label: "Webhooks", icon: Webhook, matchPrefix: "/webhooks", + perms: [WebhooksPermissions.Subscriptions.View], }, { to: "/health", label: "Health", icon: Activity, matchPrefix: "/health" }, ]; diff --git a/clients/admin/src/lib/permissions.ts b/clients/admin/src/lib/permissions.ts index 7fb8242e42..1f1524e5d8 100644 --- a/clients/admin/src/lib/permissions.ts +++ b/clients/admin/src/lib/permissions.ts @@ -68,6 +68,15 @@ export const AuditingPermissions = Object.freeze({ }, } as const); +export const WebhooksPermissions = Object.freeze({ + Subscriptions: { + View: "Permissions.Webhooks.View", + Create: "Permissions.Webhooks.Create", + Delete: "Permissions.Webhooks.Delete", + Test: "Permissions.Webhooks.Test", + }, +} as const); + // ─── Catalog (drives the Role editor) ─────────────────────────────────── export type PermissionEntry = { @@ -164,6 +173,16 @@ export const PERMISSION_CATALOG: readonly PermissionGroup[] = [ { name: IdentityPermissions.Impersonation.Revoke, description: "Revoke active impersonation grants" }, ], }, + { + category: "Webhooks", + blurb: "Manage outbound webhook subscriptions and inspect their deliveries.", + entries: [ + { name: WebhooksPermissions.Subscriptions.View, description: "View webhook subscriptions & deliveries", basic: true }, + { name: WebhooksPermissions.Subscriptions.Create, description: "Create webhook subscriptions" }, + { name: WebhooksPermissions.Subscriptions.Delete, description: "Delete webhook subscriptions" }, + { name: WebhooksPermissions.Subscriptions.Test, description: "Send test webhook deliveries" }, + ], + }, ]; export const ALL_PERMISSION_NAMES: readonly string[] = PERMISSION_CATALOG.flatMap((g) => diff --git a/clients/admin/src/routes.tsx b/clients/admin/src/routes.tsx index 8ea633ca3d..9e6840464b 100644 --- a/clients/admin/src/routes.tsx +++ b/clients/admin/src/routes.tsx @@ -12,6 +12,7 @@ import { BillingPermissions, IdentityPermissions, MultitenancyPermissions, + WebhooksPermissions, } from "@/lib/permissions"; // Lazy-loaded pages — each `import()` becomes its own bundle chunk so the @@ -192,9 +193,24 @@ export const router = createBrowserRouter([ element: , }, - // Webhooks — any signed-in user can manage their tenant's subscriptions - { path: "webhooks", element: }, - { path: "webhooks/:id", element: }, + // Webhooks — list/detail both read subscriptions, which the server + // gates on Webhooks.View (granted to Basic by default). + { + path: "webhooks", + element: ( + + + + ), + }, + { + path: "webhooks/:id", + element: ( + + + + ), + }, // Notifications inbox — available to every signed-in user { path: "notifications", element: }, diff --git a/clients/admin/tests/helpers/shell-mocks.ts b/clients/admin/tests/helpers/shell-mocks.ts index cd2e48f123..420b06e6b3 100644 --- a/clients/admin/tests/helpers/shell-mocks.ts +++ b/clients/admin/tests/helpers/shell-mocks.ts @@ -34,6 +34,10 @@ export const ADMIN_PERMS = [ "Permissions.Billing.Manage", "Permissions.AuditTrails.View", "Permissions.AuditTrails.ViewCrossTenant", + "Permissions.Webhooks.View", + "Permissions.Webhooks.Create", + "Permissions.Webhooks.Delete", + "Permissions.Webhooks.Test", ] as const; export const ADMIN_PROFILE = { diff --git a/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx b/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx index 80fb512538..f05a195946 100644 --- a/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx +++ b/clients/dashboard/src/components/command-palette/command-palette-dialog.tsx @@ -37,6 +37,7 @@ import { import { useAuth } from "@/auth/use-auth"; import { useTheme } from "@/components/theme/theme-provider"; import { accents } from "@/components/theme/appearance-options"; +import { ALL_TRASH_PERMISSIONS } from "@/lib/trash-permissions"; import { cn } from "@/lib/cn"; /** @@ -56,6 +57,14 @@ type ActionItem = { keywords?: string[]; shortcut?: string; perform: () => void; + /** + * Permission gates — same semantics as NavSpec in layout/nav-data.ts: the item + * is hidden unless the user holds `perm` AND at least one of `anyPerm`. Each + * value mirrors what the destination page's API (or the create action's + * endpoint) enforces server-side, so the palette never offers a guaranteed 403. + */ + perm?: string; + anyPerm?: readonly string[]; }; type ActionGroup = { @@ -71,8 +80,9 @@ export function CommandPaletteDialog({ onOpenChange: (next: boolean) => void; }) { const navigate = useNavigate(); - const { logout } = useAuth(); + const { user, logout } = useAuth(); const { setMode, setAccent } = useTheme(); + const permissions = useMemo(() => user?.permissions ?? [], [user]); // Build the action set fresh each time the palette opens. The ones // that navigate close the palette; the ones that mutate appearance @@ -83,7 +93,13 @@ export function CommandPaletteDialog({ navigate(path); close(); }; - return [ + // Mirrors isNavItemVisible in layout/nav-data.ts. + const visible = (item: ActionItem) => { + if (item.perm && !permissions.includes(item.perm)) return false; + if (item.anyPerm && !item.anyPerm.some((p) => permissions.includes(p))) return false; + return true; + }; + const allGroups: ActionGroup[] = [ { heading: "Navigate", items: [ @@ -110,6 +126,7 @@ export function CommandPaletteDialog({ Icon: MessageSquare, keywords: ["messages", "dm", "channel", "conversation"], perform: go("/chat"), + perm: "Permissions.Chat.Channels.View", }, { id: "nav-files", @@ -118,6 +135,7 @@ export function CommandPaletteDialog({ Icon: Folder, keywords: ["storage", "uploads", "documents"], perform: go("/files"), + perm: "Permissions.Files.Upload", }, { id: "nav-users", @@ -126,6 +144,7 @@ export function CommandPaletteDialog({ Icon: Users, keywords: ["identity", "people", "members", "team"], perform: go("/identity/users"), + perm: "Permissions.Users.Update", }, { id: "nav-roles", @@ -134,6 +153,7 @@ export function CommandPaletteDialog({ Icon: ShieldCheck, keywords: ["identity", "permissions", "rbac"], perform: go("/identity/roles"), + perm: "Permissions.Roles.Update", }, { id: "nav-groups", @@ -142,6 +162,7 @@ export function CommandPaletteDialog({ Icon: Users, keywords: ["identity", "teams", "org"], perform: go("/identity/groups"), + perm: "Permissions.Groups.Update", }, { id: "nav-products", @@ -150,6 +171,7 @@ export function CommandPaletteDialog({ Icon: Package, keywords: ["catalog", "sku", "inventory", "stock"], perform: go("/catalog/products"), + perm: "Permissions.Catalog.Products.View", }, { id: "nav-brands", @@ -158,6 +180,7 @@ export function CommandPaletteDialog({ Icon: Tag, keywords: ["catalog"], perform: go("/catalog/brands"), + perm: "Permissions.Catalog.Brands.View", }, { id: "nav-categories", @@ -166,6 +189,7 @@ export function CommandPaletteDialog({ Icon: Boxes, keywords: ["catalog"], perform: go("/catalog/categories"), + perm: "Permissions.Catalog.Categories.View", }, { id: "nav-tickets", @@ -174,6 +198,7 @@ export function CommandPaletteDialog({ Icon: LifeBuoy, keywords: ["support", "issues", "helpdesk"], perform: go("/tickets"), + perm: "Permissions.Tickets.View", }, { id: "nav-invoices", @@ -182,6 +207,7 @@ export function CommandPaletteDialog({ Icon: Receipt, keywords: ["billing", "payment"], perform: go("/invoices"), + perm: "Permissions.Billing.View", }, { id: "nav-health", @@ -198,6 +224,7 @@ export function CommandPaletteDialog({ Icon: ScrollText, keywords: ["audit", "log", "compliance", "security", "trace", "correlation"], perform: go("/system/audits"), + perm: "Permissions.AuditTrails.View", }, { id: "nav-trash", @@ -206,6 +233,7 @@ export function CommandPaletteDialog({ Icon: ScrollText, keywords: ["recycle", "deleted", "restore"], perform: go("/system/trash"), + anyPerm: ALL_TRASH_PERMISSIONS, }, { id: "nav-sessions", @@ -214,6 +242,7 @@ export function CommandPaletteDialog({ Icon: Shield, keywords: ["devices", "logins"], perform: go("/system/sessions"), + perm: "Permissions.Sessions.ViewAll", }, { id: "nav-settings", @@ -234,6 +263,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "invite", "register", "identity"], perform: go("/identity/users?action=create"), + perm: "Permissions.Users.Create", }, { id: "create-role", @@ -242,6 +272,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "permissions", "rbac"], perform: go("/identity/roles?action=create"), + perm: "Permissions.Roles.Create", }, { id: "create-group", @@ -250,6 +281,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "team", "org"], perform: go("/identity/groups?action=create"), + perm: "Permissions.Groups.Create", }, { id: "create-product", @@ -258,6 +290,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "catalog", "sku"], perform: go("/catalog/products?action=create"), + perm: "Permissions.Catalog.Products.Create", }, { id: "create-brand", @@ -266,6 +299,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "catalog"], perform: go("/catalog/brands?action=create"), + perm: "Permissions.Catalog.Brands.Create", }, { id: "create-category", @@ -274,6 +308,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "catalog"], perform: go("/catalog/categories?action=create"), + perm: "Permissions.Catalog.Categories.Create", }, { id: "create-ticket", @@ -282,6 +317,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "support", "issue"], perform: go("/tickets?action=create"), + perm: "Permissions.Tickets.Create", }, { id: "create-channel", @@ -290,6 +326,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "chat", "channel"], perform: go("/chat?action=create-channel"), + perm: "Permissions.Chat.Channels.Create", }, { id: "create-file", @@ -298,6 +335,7 @@ export function CommandPaletteDialog({ Icon: Plus, keywords: ["new", "upload", "attach"], perform: go("/files?action=upload"), + perm: "Permissions.Files.Upload", }, ], }, @@ -398,7 +436,12 @@ export function CommandPaletteDialog({ ], }, ]; - }, [navigate, onOpenChange, setMode, setAccent, logout]); + // Drop items the user can't access, then drop any group left empty — + // same shape as visibleSections() in layout/nav-data.ts. + return allGroups + .map((g) => ({ ...g, items: g.items.filter(visible) })) + .filter((g) => g.items.length > 0); + }, [navigate, onOpenChange, setMode, setAccent, logout, permissions]); return ( diff --git a/clients/dashboard/src/components/layout/nav-data.ts b/clients/dashboard/src/components/layout/nav-data.ts index 37e2f837b4..5eda502fa3 100644 --- a/clients/dashboard/src/components/layout/nav-data.ts +++ b/clients/dashboard/src/components/layout/nav-data.ts @@ -53,8 +53,11 @@ export type NavSection = { // Settings is account-scoped and lives at the very bottom. export const topNavTop: NavSpec[] = [ { to: "/", label: "Overview", icon: LayoutDashboard }, - { to: "/chat", label: "Chat", icon: MessageCircle }, - { to: "/files", label: "My Files", icon: FolderOpen }, + // Each gate mirrors the permission the page's primary list endpoint enforces + // server-side (Chat → channels list, Files → /files/mine). Same convention + // as trash-permissions.ts: if the endpoint's permission changes, mirror it. + { to: "/chat", label: "Chat", icon: MessageCircle, perm: "Permissions.Chat.Channels.View" }, + { to: "/files", label: "My Files", icon: FolderOpen, perm: "Permissions.Files.Upload" }, ]; export const topNavBottom: NavSpec[] = [ @@ -68,9 +71,10 @@ export const sections: NavSection[] = [ caption: "Operations", icon: Activity, items: [ + // Live activity is SSE-backed; the stream is auth-only (no permission), so no gate. { to: "/activity", label: "Live activity", icon: Activity }, - { to: "/subscription", label: "Subscription", icon: CreditCard }, - { to: "/invoices", label: "Invoices", icon: Receipt }, + { to: "/subscription", label: "Subscription", icon: CreditCard, perm: "Permissions.Billing.View" }, + { to: "/invoices", label: "Invoices", icon: Receipt, perm: "Permissions.Billing.View" }, ], }, { @@ -78,9 +82,9 @@ export const sections: NavSection[] = [ caption: "Catalog", icon: Package, items: [ - { to: "/catalog/products", label: "Products", icon: Package }, - { to: "/catalog/brands", label: "Brands", icon: Tags }, - { to: "/catalog/categories", label: "Categories", icon: FolderTree }, + { to: "/catalog/products", label: "Products", icon: Package, perm: "Permissions.Catalog.Products.View" }, + { to: "/catalog/brands", label: "Brands", icon: Tags, perm: "Permissions.Catalog.Brands.View" }, + { to: "/catalog/categories", label: "Categories", icon: FolderTree, perm: "Permissions.Catalog.Categories.View" }, ], }, { @@ -88,7 +92,7 @@ export const sections: NavSection[] = [ caption: "Helpdesk", icon: Ticket, items: [ - { to: "/tickets", label: "Tickets", icon: Ticket }, + { to: "/tickets", label: "Tickets", icon: Ticket, perm: "Permissions.Tickets.View" }, ], }, { @@ -109,6 +113,7 @@ export const sections: NavSection[] = [ caption: "System", icon: HeartPulse, items: [ + // Health hits the anonymous /health/ready probe — visible to everyone. { to: "/system/health", label: "Health", icon: HeartPulse }, { to: "/system/audits", label: "Audit trail", icon: ScrollText, perm: "Permissions.AuditTrails.View" }, { to: "/system/sessions", label: "Sessions", icon: Wifi, perm: "Permissions.Sessions.ViewAll" }, diff --git a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs index afcfe8ff0b..74ec5d9f43 100644 --- a/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs +++ b/src/Host/FSH.Starter.DbMigrator/DemoSeed/DemoSeeder.cs @@ -7,15 +7,18 @@ using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Catalog.Contracts.Authorization; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; +using FSH.Modules.Identity.Contracts.Authorization; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; using FSH.Modules.Multitenancy.Contracts; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Provisioning; +using FSH.Modules.Tickets.Contracts.Authorization; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -734,48 +737,52 @@ private static IReadOnlyList BuildGlobexUsers() => new("globex.dave", "dave@globex.com", "Dave", "Hartwell", [RoleConstants.Basic]), ]; + // Permission claims reference the module contracts constants — never raw strings. + // A hand-typed name that doesn't match a registry entry (e.g. the old + // "Permissions.Brands.View" vs the real "Permissions.Catalog.Brands.View") + // is a claim that grants nothing, silently. private static IReadOnlyList BuildAcmeCustomRoles() => [ new( "Manager", "Operations manager — full catalog + tickets + read-only users.", [ - "Permissions.Users.View", - "Permissions.Users.Update", - "Permissions.UserRoles.View", - "Permissions.Roles.View", - "Permissions.Sessions.View", - "Permissions.Sessions.Revoke", - "Permissions.Groups.View", - "Permissions.Brands.View", - "Permissions.Brands.Create", - "Permissions.Brands.Update", - "Permissions.Brands.Delete", - "Permissions.Categories.View", - "Permissions.Categories.Create", - "Permissions.Categories.Update", - "Permissions.Categories.Delete", - "Permissions.Products.View", - "Permissions.Products.Create", - "Permissions.Products.Update", - "Permissions.Products.Delete", - "Permissions.Tickets.View", - "Permissions.Tickets.Create", - "Permissions.Tickets.Update", - "Permissions.Tickets.Delete", + IdentityPermissions.Users.View, + IdentityPermissions.Users.Update, + IdentityPermissions.UserRoles.View, + IdentityPermissions.Roles.View, + IdentityPermissions.Sessions.View, + IdentityPermissions.Sessions.Revoke, + IdentityPermissions.Groups.View, + CatalogPermissions.Brands.View, + CatalogPermissions.Brands.Create, + CatalogPermissions.Brands.Update, + CatalogPermissions.Brands.Delete, + CatalogPermissions.Categories.View, + CatalogPermissions.Categories.Create, + CatalogPermissions.Categories.Update, + CatalogPermissions.Categories.Delete, + CatalogPermissions.Products.View, + CatalogPermissions.Products.Create, + CatalogPermissions.Products.Update, + CatalogPermissions.Products.Delete, + TicketsPermissions.Tickets.View, + TicketsPermissions.Tickets.Create, + TicketsPermissions.Tickets.Update, + TicketsPermissions.Tickets.Delete, ]), new( "Support", "Support agent — full tickets + read-only users.", [ - "Permissions.Users.View", - "Permissions.UserRoles.View", - "Permissions.Sessions.View", - "Permissions.Sessions.Revoke", - "Permissions.Tickets.View", - "Permissions.Tickets.Create", - "Permissions.Tickets.Update", + IdentityPermissions.Users.View, + IdentityPermissions.UserRoles.View, + IdentityPermissions.Sessions.View, + IdentityPermissions.Sessions.Revoke, + TicketsPermissions.Tickets.View, + TicketsPermissions.Tickets.Create, + TicketsPermissions.Tickets.Update, ]), ]; From e5147132202b2d1777c1c0260263ace1e7206429 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Fri, 12 Jun 2026 18:17:21 +0530 Subject: [PATCH 2/2] fix(deps): pin transitive MessagePack to 2.5.301 (NU1903 audit failure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub advisory GHSA-hv8m-jj95-wg3x (high severity, LZ4 decompression AccessViolationException) was published against MessagePack < 2.5.301, which Microsoft.AspNetCore.SignalR.StackExchangeRedis pulls in at 2.5.187. NuGet audit runs with warnings-as-errors, so every restore in CI now fails with NU1903 — breaking all PR checks regardless of their content. CentralPackageTransitivePinningEnabled is on, so a single PackageVersion entry bumps the transitive across the solution. Remove the pin once the SignalR backplane package references a patched MessagePack itself. Co-Authored-By: Claude Fable 5 --- src/Directory.Packages.props | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 404daeb6f5..c4661c3054 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -120,4 +120,11 @@ + + + + \ No newline at end of file