feat: allow to restrict routes based on permissions (#8287)

This commit is contained in:
Alejandro Bailo
2025-07-16 10:36:45 +02:00
committed by GitHub
parent f845176494
commit c5b1bf3e52
14 changed files with 222 additions and 86 deletions
+1
View File
@@ -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
+16 -1
View File
@@ -143,7 +143,7 @@ export const getToken = async (formData: z.infer<typeof formSchemaSignIn>) => {
};
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");
+12
View File
@@ -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) {
@@ -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 (
<>
<Suspense fallback={<SkeletonContentLayout />}>
<Navbar title={title} icon={icon} user={user} />
</Suspense>
<Navbar title={title} icon={icon} />
<div className="px-6 py-4 sm:px-8 xl:px-10">{children}</div>
</>
);
+2 -4
View File
@@ -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) {
</div>
<div className="flex flex-1 items-center justify-end gap-3">
<ThemeSwitch />
<UserNav user={user} />
<UserNav />
</div>
</div>
</header>
+111 -63
View File
@@ -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 (
<>
<div className="px-2">
@@ -43,7 +88,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
<ScrollArea className="[&>div>div[style]]:!block">
<nav className="mt-2 h-full w-full lg:mt-6">
<ul className="flex min-h-[calc(100vh-16px-60px-40px-16px-32px-40px-32px-44px)] flex-col items-start space-y-1 px-2 lg:min-h-[calc(100vh-16px-60px-40px-16px-64px-16px-41px)]">
{menuList.map(({ groupLabel, menus }, index) => (
{filteredMenuList.map(({ groupLabel, menus }, index) => (
<li
className={cn(
"w-full",
@@ -72,68 +117,71 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
) : (
<p className="pb-2"></p>
)}
{menus.map(
(
{ href, label, icon: Icon, active, submenus, defaultOpen },
index,
) =>
!submenus || submenus.length === 0 ? (
<div className="w-full" key={index}>
<TooltipProvider disableHoverableContent>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant={
(active === undefined &&
pathname.startsWith(href)) ||
active
? "secondary"
: "ghost"
}
className="mb-1 h-8 w-full justify-start"
asChild
>
<Link href={href}>
<span
className={cn(
isOpen === false ? "" : "mr-4",
)}
>
<Icon size={18} />
</span>
<p
className={cn(
"max-w-[200px] truncate",
isOpen === false
? "-translate-x-96 opacity-0"
: "translate-x-0 opacity-100",
)}
>
{label}
</p>
</Link>
</Button>
</TooltipTrigger>
{isOpen === false && (
<TooltipContent side="right">
{label}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
) : (
<div className="w-full" key={index}>
<CollapseMenuButton
icon={Icon}
label={label}
submenus={submenus}
isOpen={isOpen}
defaultOpen={defaultOpen ?? false}
/>
</div>
),
)}
{menus.map((menu: any, index: number) => {
const {
href,
label,
icon: Icon,
active,
submenus,
defaultOpen,
} = menu;
return !submenus || submenus.length === 0 ? (
<div className="w-full" key={index}>
<TooltipProvider disableHoverableContent>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant={
(active === undefined &&
pathname.startsWith(href)) ||
active
? "secondary"
: "ghost"
}
className="mb-1 h-8 w-full justify-start"
asChild
>
<Link href={href}>
<span
className={cn(isOpen === false ? "" : "mr-4")}
>
<Icon size={18} />
</span>
<p
className={cn(
"max-w-[200px] truncate",
isOpen === false
? "-translate-x-96 opacity-0"
: "translate-x-0 opacity-100",
)}
>
{label}
</p>
</Link>
</Button>
</TooltipTrigger>
{isOpen === false && (
<TooltipContent side="right">
{label}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
) : (
<div className="w-full" key={index}>
<CollapseMenuButton
icon={Icon}
label={label}
submenus={submenus}
isOpen={isOpen}
defaultOpen={defaultOpen ?? false}
/>
</div>
);
})}
</li>
))}
</ul>
+8 -6
View File
@@ -2,6 +2,7 @@
import { LogOut, User } from "lucide-react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { logOut } from "@/actions/auth";
import {
@@ -24,14 +25,15 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip/tooltip";
import { UserProfileProps } from "@/types";
import { Button } from "../button/button";
export const UserNav = ({ user }: { user?: UserProfileProps }) => {
if (!user || !user.data) return null;
export const UserNav = () => {
const { data: session } = useSession();
const { name, email, company_name } = user.data.attributes;
if (!session?.user) return null;
const { name, email, companyName } = session.user;
return (
<DropdownMenu>
@@ -66,8 +68,8 @@ export const UserNav = ({ user }: { user?: UserProfileProps }) => {
<div className="flex flex-col space-y-1">
<p className="text-small font-medium leading-none">
{name}
{company_name && (
<span className="text-xs">{` | ${company_name}`}</span>
{companyName && (
<span className="text-xs">{` | ${companyName}`}</span>
)}
</p>
<p className="text-muted-foreground text-xs leading-none">
@@ -50,7 +50,11 @@ export const UserBasicInfoCard = ({
</div>
<div className="flex flex-col gap-2">
<InfoField label="Organization ID" variant="transparent">
<TenantIdCopy id={tenantId} />
{tenantId ? (
<TenantIdCopy id={tenantId} />
) : (
<span className="text-xs font-light">No organization</span>
)}
</InfoField>
</div>
</div>
+2
View File
@@ -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";
+31
View File
@@ -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,
};
}
+1
View File
@@ -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";
+24 -3
View File
@@ -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
+4
View File
@@ -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;
+2
View File
@@ -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;