diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 5dd58450b4..1ec28cc376 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Mutelist configuration form [(#8190)](https://github.com/prowler-cloud/prowler/pull/8190) - SAML login integration [(#8203)](https://github.com/prowler-cloud/prowler/pull/8203) +- Allow to restrict routes access based on user permissions [(#8287)](https://github.com/prowler-cloud/prowler/pull/8287) ### Security diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index e0e3a75e20..fe93e03f04 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -143,7 +143,7 @@ export const getToken = async (formData: z.infer) => { }; export const getUserByMe = async (accessToken: string) => { - const url = new URL(`${apiBaseUrl}/users/me`); + const url = new URL(`${apiBaseUrl}/users/me?include=roles`); try { const response = await fetch(url.toString(), { @@ -171,11 +171,26 @@ export const getUserByMe = async (accessToken: string) => { } } + const userRole = parsedResponse.included?.find( + (item: any) => item.type === "roles", + ); + + const permissions = { + manage_users: userRole.attributes.manage_users || false, + manage_account: userRole.attributes.manage_account || false, + manage_providers: userRole.attributes.manage_providers || false, + manage_scans: userRole.attributes.manage_scans || false, + manage_integrations: userRole.attributes.manage_integrations || false, + manage_billing: userRole.attributes.manage_billing || false, + unlimited_visibility: userRole.attributes.unlimited_visibility || false, + }; + return { name: parsedResponse.data.attributes.name, email: parsedResponse.data.attributes.email, company: parsedResponse.data.attributes.company_name, dateJoined: parsedResponse.data.attributes.date_joined, + permissions, }; } catch (error: any) { throw new Error(error.message || "Network error or server unreachable"); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index ed15ed4a38..a317a1a800 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -90,6 +90,7 @@ export const authConfig = { email: userMeResponse.email, company: userMeResponse?.company, dateJoined: userMeResponse.dateJoined, + permissions: userMeResponse.permissions, }; return { @@ -121,6 +122,8 @@ export const authConfig = { email: userMeResponse.email, company: userMeResponse?.company, dateJoined: userMeResponse.dateJoined, + + permissions: userMeResponse.permissions, }; return { @@ -171,6 +174,15 @@ export const authConfig = { companyName: user?.company, email: user?.email, dateJoined: user?.dateJoined, + permissions: user?.permissions || { + manage_users: false, + manage_account: false, + manage_providers: false, + manage_scans: false, + manage_integrations: false, + manage_billing: false, + unlimited_visibility: false, + }, }; if (account && user) { diff --git a/ui/components/ui/content-layout/content-layout.tsx b/ui/components/ui/content-layout/content-layout.tsx index 518fb58efa..181cdea764 100644 --- a/ui/components/ui/content-layout/content-layout.tsx +++ b/ui/components/ui/content-layout/content-layout.tsx @@ -1,9 +1,8 @@ -import { ReactNode, Suspense, use } from "react"; +"use client"; -import { getUserInfo } from "@/actions/users/users"; +import { ReactNode } from "react"; import { Navbar } from "../nav-bar/navbar"; -import { SkeletonContentLayout } from "./skeleton-content-layout"; interface ContentLayoutProps { title: string; @@ -12,13 +11,9 @@ interface ContentLayoutProps { } export function ContentLayout({ title, icon, children }: ContentLayoutProps) { - const user = use(getUserInfo()); - return ( <> - }> - - +
{children}
); diff --git a/ui/components/ui/nav-bar/navbar.tsx b/ui/components/ui/nav-bar/navbar.tsx index 6d7c633e17..147b38862a 100644 --- a/ui/components/ui/nav-bar/navbar.tsx +++ b/ui/components/ui/nav-bar/navbar.tsx @@ -7,7 +7,6 @@ import { usePathname, useSearchParams } from "next/navigation"; import { ReactNode } from "react"; import { ThemeSwitch } from "@/components/ThemeSwitch"; -import { UserProfileProps } from "@/types"; import { SheetMenu } from "../sidebar/sheet-menu"; import { UserNav } from "../user-nav/user-nav"; @@ -15,7 +14,6 @@ import { UserNav } from "../user-nav/user-nav"; interface NavbarProps { title: string; icon: string | ReactNode; - user: UserProfileProps; } interface BreadcrumbItem { @@ -24,7 +22,7 @@ interface BreadcrumbItem { isLast: boolean; } -export function Navbar({ title, icon, user }: NavbarProps) { +export function Navbar({ title, icon }: NavbarProps) { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -117,7 +115,7 @@ export function Navbar({ title, icon, user }: NavbarProps) {
- +
diff --git a/ui/components/ui/sidebar/menu.tsx b/ui/components/ui/sidebar/menu.tsx index f8c1188f10..c42d1e5d98 100644 --- a/ui/components/ui/sidebar/menu.tsx +++ b/ui/components/ui/sidebar/menu.tsx @@ -14,6 +14,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip/tooltip"; +import { useAuth } from "@/hooks"; import { getMenuList } from "@/lib/menu-list"; import { cn } from "@/lib/utils"; @@ -21,10 +22,54 @@ import { Button } from "../button/button"; import { CustomButton } from "../custom/custom-button"; import { ScrollArea } from "../scroll-area/scroll-area"; +interface MenuHideRule { + label: string; + condition: (permissions: any) => boolean; +} + +// Configuration for hiding menu items based on permissions +const MENU_HIDE_RULES: MenuHideRule[] = [ + { + label: "Billing", + condition: (permissions) => permissions?.manage_billing === false, + }, + // Add more rules as needed: + // { + // label: "Users", + // condition: (permissions) => !permissions?.manage_users + // }, + // { + // label: "Configuration", + // condition: (permissions) => !permissions?.manage_providers + // }, +]; + +const hideMenuItems = (menuGroups: any[], labelsToHide: string[]) => { + return menuGroups.map((group) => ({ + ...group, + menus: group.menus + .filter((menu: any) => !labelsToHide.includes(menu.label)) + .map((menu: any) => ({ + ...menu, + submenus: + menu.submenus?.filter( + (submenu: any) => !labelsToHide.includes(submenu.label), + ) || [], + })), + })); +}; + export const Menu = ({ isOpen }: { isOpen: boolean }) => { const pathname = usePathname(); + const { permissions } = useAuth(); const menuList = getMenuList(pathname); + const labelsToHide = MENU_HIDE_RULES.filter((rule) => + rule.condition(permissions), + ).map((rule) => rule.label); + + const filteredMenuList = hideMenuItems(menuList, labelsToHide); + return ( <>
@@ -43,7 +88,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
diff --git a/ui/hooks/index.ts b/ui/hooks/index.ts index 1c42f6d01f..f03f9b4ba0 100644 --- a/ui/hooks/index.ts +++ b/ui/hooks/index.ts @@ -1,6 +1,8 @@ +export * from "./use-auth"; export * from "./use-credentials-form"; export * from "./use-form-server-errors"; export * from "./use-local-storage"; export * from "./use-related-filters"; export * from "./use-sidebar"; export * from "./use-store"; +export * from "./use-url-filters"; diff --git a/ui/hooks/use-auth.ts b/ui/hooks/use-auth.ts new file mode 100644 index 0000000000..58b55349d5 --- /dev/null +++ b/ui/hooks/use-auth.ts @@ -0,0 +1,31 @@ +import { useSession } from "next-auth/react"; + +export function useAuth() { + const { data: session, status } = useSession(); + + const isLoading = status === "loading"; + const isAuthenticated = !!session?.user; + + const permissions = session?.user?.permissions || { + manage_users: false, + manage_account: false, + manage_providers: false, + manage_scans: false, + manage_integrations: false, + manage_billing: false, + unlimited_visibility: false, + }; + + const hasPermission = (permission: keyof typeof permissions) => { + return permissions[permission] === true; + }; + + return { + session, + isLoading, + isAuthenticated, + user: session?.user, + permissions, + hasPermission, + }; +} diff --git a/ui/lib/index.ts b/ui/lib/index.ts index 8d50d049be..d5a6948833 100644 --- a/ui/lib/index.ts +++ b/ui/lib/index.ts @@ -3,4 +3,5 @@ export * from "./external-urls"; export * from "./helper"; export * from "./helper-filters"; export * from "./menu-list"; +export * from "./permissions"; export * from "./utils"; diff --git a/ui/middleware.ts b/ui/middleware.ts index f6f1af8974..a8a754c879 100644 --- a/ui/middleware.ts +++ b/ui/middleware.ts @@ -1,8 +1,29 @@ -import NextAuth from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; -import { authConfig } from "./auth.config"; +import { auth } from "@/auth.config"; -export default NextAuth(authConfig).auth; +export default auth((req: NextRequest & { auth: any }) => { + const { pathname } = req.nextUrl; + const user = req.auth?.user; + + if ( + !user && + !pathname.includes("/sign-in") && + !pathname.includes("/sign-up") + ) { + return NextResponse.redirect(new URL("/sign-in", req.url)); + } + + if (user?.permissions) { + const permissions = user.permissions; + + if (pathname.startsWith("/billing") && !permissions.manage_billing) { + return NextResponse.redirect(new URL("/", req.url)); + } + } + + return NextResponse.next(); +}); export const config = { // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher diff --git a/ui/nextauth.d.ts b/ui/nextauth.d.ts index d5b6f472f9..ffb22bc89b 100644 --- a/ui/nextauth.d.ts +++ b/ui/nextauth.d.ts @@ -1,11 +1,14 @@ import { DefaultSession } from "next-auth"; +import { RolePermissionAttributes } from "./types/users"; + declare module "next-auth" { interface User extends NextAuthUser { name: string; email: string; company?: string; dateJoined: string; + permissions?: RolePermissionAttributes; } interface Session extends DefaultSession { @@ -14,6 +17,7 @@ declare module "next-auth" { email: string; companyName?: string; dateJoined: string; + permissions: RolePermissionAttributes; } & DefaultSession["user"]; userId: string; tenantId: string; diff --git a/ui/types/users.ts b/ui/types/users.ts index 993f1bf286..3ecd835d96 100644 --- a/ui/types/users.ts +++ b/ui/types/users.ts @@ -62,6 +62,7 @@ export type PermissionKey = | "manage_providers" | "manage_scans" | "manage_integrations" + | "manage_billing" | "unlimited_visibility"; export type RolePermissionAttributes = Pick< @@ -79,6 +80,7 @@ export interface RoleDetail { manage_providers: boolean; manage_scans: boolean; manage_integrations: boolean; + manage_billing?: boolean; unlimited_visibility: boolean; permission_state?: string; inserted_at?: string;