mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
feat: allow to restrict routes based on permissions (#8287)
This commit is contained in:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user