feat(pam): PAM Platform V1

This commit is contained in:
x032205
2025-09-27 01:57:43 -04:00
parent 270d8237fe
commit 4b1664c30f
141 changed files with 9327 additions and 46 deletions
@@ -0,0 +1,37 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPamSessionActions } from "@app/context/ProjectPermissionContext/types";
import { PamSessionSection } from "./components/PamSessionSection";
export const PamSessionPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "PAM" })}</title>
</Helmet>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionPamSessionActions.Read}
a={ProjectPermissionSub.PamSessions}
>
<div className="h-full bg-bunker-800">
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Sessions"
description="Filter and search through account sessions."
/>
<PamSessionSection />
</div>
</div>
</div>
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,198 @@
import { useState } from "react";
import {
faBoxOpen,
faChevronDown,
faChevronUp,
faEdit,
faEllipsisV,
faTerminal
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { HighlightText } from "@app/components/v2/HighlightText";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
type Props = {
session: TPamSession;
search: string;
filteredCommandLogs: TPamSession["commandLogs"];
};
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
const router = useRouter();
const [showAllLogs, setShowAllLogs] = useState(false);
const {
id,
accountName,
resourceType,
resourceName,
projectId,
status,
actorName,
actorEmail,
createdAt,
endedAt
} = session;
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
const LOGS_TO_SHOW = 5;
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
return (
<>
<Tr
className={twMerge("group h-10 cursor-pointer hover:bg-bunker-400/20")}
onClick={() => router.history.push(`/projects/pam/${projectId}/sessions/${id}`)}
>
<Td>
<div className="flex items-center gap-4">
<div className="relative">
<img
alt={resourceTypeName}
src={`/images/integrations/${image}`}
className="size-6"
/>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col">
<span>
<HighlightText text={accountName} highlight={search} />
</span>
<div className="flex items-center gap-1 text-xs text-bunker-300">
<FontAwesomeIcon icon={faBoxOpen} className="size-3" />
<span>
<HighlightText text={resourceName} highlight={search} />
</span>
</div>
</div>
</div>
</div>
</Td>
<Td>
<div className="flex flex-col">
<span>
<HighlightText text={actorName} highlight={search} />
</span>
<span className="text-xs text-bunker-300">
<HighlightText text={actorEmail} highlight={search} />
</span>
</div>
</Td>
<Td>
<div className="flex flex-col">
<span>{new Date(createdAt).toLocaleTimeString()}</span>
<span className="text-xs text-bunker-300">
{new Date(createdAt).toLocaleDateString()}
</span>
</div>
</Td>
<Td>
{endedAt ? (
<div className="flex flex-col">
<span>{new Date(endedAt).toLocaleTimeString()}</span>
<span className="text-xs text-bunker-300">
{new Date(endedAt).toLocaleDateString()}
</span>
</div>
) : (
<span className="text-bunker-400">Ongoing</span>
)}
</Td>
<Td>
<div className="flex items-center justify-end gap-2">
<PamSessionStatusBadge status={status} />
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.PamResources}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() =>
router.history.push(`/projects/pam/${projectId}/sessions/${id}`)
}
>
View Session
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
{filteredCommandLogs.length > 0 && (
<Tr>
<Td colSpan={5} className="py-3 text-xs">
{logsToShow.map((log) => (
<div key={`${id}-log-${log.timestamp}`} className="mb-4 flex flex-col last:mb-0">
<div className="flex items-center gap-1.5 text-bunker-400">
<FontAwesomeIcon icon={faTerminal} className="size-3" />
<span>{new Date(log.timestamp).toLocaleString()}</span>
</div>
<div className="font-mono">
<HighlightText text={log.input} highlight={search} />
</div>
<div className="font-mono text-bunker-300">
<HighlightText text={log.output} highlight={search} />
</div>
</div>
))}
{filteredCommandLogs.length > LOGS_TO_SHOW && (
<div className="mt-2">
<Button
variant="link"
size="xs"
leftIcon={<FontAwesomeIcon icon={showAllLogs ? faChevronUp : faChevronDown} />}
onClick={() => setShowAllLogs(!showAllLogs)}
className="p-0 text-mineshaft-300 hover:text-primary"
>
{showAllLogs
? "Show less"
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
</Button>
</div>
)}
</Td>
</Tr>
)}
</>
);
};
@@ -0,0 +1,17 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamSessions } from "@app/hooks/api/pam";
import { PamSessionsTable } from "./PamSessionsTable";
export const PamSessionSection = () => {
const { currentProject } = useProject();
const { data: sessions = [], isPending } = useListPamSessions(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return <PamSessionsTable sessions={sessions} />;
};
@@ -0,0 +1,63 @@
import {
faBan,
faCircle,
faGavel,
faHourglass,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Badge } from "@app/components/v2";
import { PamSessionStatus } from "@app/hooks/api/pam";
interface StatusConfig {
bgColor: string;
textColor: string;
icon: IconDefinition;
iconClassName?: string;
}
const PAM_SESSION_STATUS_CONFIG: Record<PamSessionStatus, StatusConfig> = {
[PamSessionStatus.Active]: {
bgColor: "bg-green/20",
textColor: "text-green",
icon: faCircle,
iconClassName: "size-2 animate-pulse"
},
[PamSessionStatus.Terminated]: {
bgColor: "bg-red/20",
textColor: "text-red",
icon: faGavel
},
[PamSessionStatus.Starting]: {
bgColor: "bg-yellow/20",
textColor: "text-yellow",
icon: faHourglass,
iconClassName: "animate-spin"
},
[PamSessionStatus.Ended]: {
bgColor: "bg-bunker-300/20",
textColor: "text-bunker-300",
icon: faBan
}
};
export const PamSessionStatusBadge = ({ status }: { status: PamSessionStatus }) => {
const config = PAM_SESSION_STATUS_CONFIG[status];
const displayName = status[0].toUpperCase() + status.slice(1);
return (
<Badge
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap",
config.bgColor,
config.textColor
)}
>
<FontAwesomeIcon icon={config.icon} className={config.iconClassName} />
{displayName}
</Badge>
);
};
@@ -0,0 +1,411 @@
import { useEffect, useMemo, useState } from "react";
import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
PAM_RESOURCE_TYPE_MAP,
PamResourceType,
PamSessionStatus,
TPamSession
} from "@app/hooks/api/pam";
import { PamSessionRow } from "./PamSessionRow";
enum OrderBy {
Account = "account",
Actor = "actor",
CreatedAt = "createdAt",
EndedAt = "endedAt"
}
type Filters = {
resourceType: PamResourceType[];
status: PamSessionStatus[];
};
type Props = {
sessions: TPamSession[];
};
export const PamSessionsTable = ({ sessions }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.SessionsPage.path });
const { search: initSearch } = useSearch({
from: ROUTE_PATHS.Pam.SessionsPage.id
});
const [filters, setFilters] = useState<Filters>({
resourceType: [],
status: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.CreatedAt, {
initPerPage: 20,
initSearch
});
useEffect(() => {
setOrderDirection(OrderByDirection.DESC);
}, []);
const sessionsWithMatches = useMemo(() => {
const searchValue = search.trim().toLowerCase();
if (!searchValue) {
return sessions.map((session) => ({
session,
isMatch: true,
filteredLogs: []
}));
}
return sessions.map((session) => {
const {
resourceType,
accountName,
actorEmail,
actorIp,
actorName,
actorUserAgent,
id,
resourceName,
userId,
commandLogs
} = session;
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
const isMetaMatch =
resourceTypeName.toLowerCase().includes(searchValue) ||
accountName.toLowerCase().includes(searchValue) ||
actorEmail.toLowerCase().includes(searchValue) ||
actorIp.toLowerCase().includes(searchValue) ||
actorName.toLowerCase().includes(searchValue) ||
actorUserAgent.toLowerCase().includes(searchValue) ||
id.toLowerCase().includes(searchValue) ||
(userId ?? "").toLowerCase().includes(searchValue) ||
resourceName.toLowerCase().includes(searchValue);
const filteredLogs =
searchValue.length >= 2
? commandLogs.filter(
(log) =>
log.input.toLowerCase().includes(searchValue) ||
log.output.toLowerCase().includes(searchValue)
)
: [];
return {
session,
isMatch: isMetaMatch || filteredLogs.length > 0,
filteredLogs
};
});
}, [sessions, search]);
const filteredSessions = useMemo(
() =>
sessionsWithMatches
.filter((item) => {
if (!item.isMatch) return false;
const { resourceType, status } = item.session;
if (
(filters.resourceType.length && !filters.resourceType.includes(resourceType)) ||
(filters.status.length && !filters.status.includes(status))
) {
return false;
}
return true;
})
.sort((a, b) => {
const [one, two] =
orderDirection === OrderByDirection.ASC
? [a.session, b.session]
: [b.session, a.session];
switch (orderBy) {
case OrderBy.Account:
return one.accountName.toLowerCase().localeCompare(two.accountName.toLowerCase());
case OrderBy.Actor:
return one.actorName.toLowerCase().localeCompare(two.actorName.toLowerCase());
case OrderBy.EndedAt: {
const dateOne = one.endedAt || one.createdAt;
const dateTwo = two.endedAt || two.createdAt;
return new Date(dateOne).getTime() - new Date(dateTwo).getTime();
}
case OrderBy.CreatedAt:
default: {
const dateOne = one.createdAt;
const dateTwo = two.createdAt;
return new Date(dateOne).getTime() - new Date(dateTwo).getTime();
}
}
}),
[sessionsWithMatches, orderDirection, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredSessions.length,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredSessions.slice(offset, perPage * page),
[filteredSessions, offset, perPage, page]
);
const handleSort = (column: OrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceType.length || filters.status.length);
const isContentEmpty = !filteredSessions.length;
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => {
const newSearch = e.target.value;
setSearch(newSearch);
navigate({
search: (prev) => ({ ...prev, search: newSearch || undefined }),
replace: true
});
}}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search sessions and logs..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter sessions"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Session Status</DropdownMenuLabel>
{sessions.length ? (
[...new Set(sessions.map(({ status }) => status))].map((status) => {
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((a) => a !== status)
: [...prev.status, status]
}));
}}
key={status}
icon={
filters.status.includes(status) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
{status[0].toUpperCase() + status.slice(1)}
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Sessions</DropdownMenuItem>
)}
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
{sessions.length ? (
[...new Set(sessions.map(({ resourceType }) => resourceType))].map((type) => {
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
resourceType: prev.resourceType.includes(type)
? prev.resourceType.filter((a) => a !== type)
: [...prev.resourceType, type]
}));
}}
key={type}
icon={
filters.resourceType.includes(type) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${name} resource type`}
src={`/images/integrations/${image}`}
className="h-4 w-4"
/>
<span>{name}</span>
</div>
</DropdownMenuItem>
);
})
) : (
<DropdownMenuItem isDisabled>No Sessions</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Account
<IconButton
variant="plain"
className={getClassName(OrderBy.Account)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Account)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Account)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Actor
<IconButton
variant="plain"
className={getClassName(OrderBy.Actor)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Actor)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Actor)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Created At
<IconButton
variant="plain"
className={getClassName(OrderBy.CreatedAt)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.CreatedAt)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.CreatedAt)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Ended At
<IconButton
variant="plain"
className={getClassName(OrderBy.EndedAt)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.EndedAt)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.EndedAt)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{currentPageData.map(({ session, filteredLogs }) => (
<PamSessionRow
key={session.id}
session={session}
search={search.trim().toLowerCase()}
filteredCommandLogs={filteredLogs}
/>
))}
</TBody>
</Table>
{Boolean(filteredSessions.length) && (
<Pagination
count={filteredSessions.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No sessions match search" : "No sessions"}
icon={isSearchEmpty ? faSearch : faCircleXmark}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1,26 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { PamSessionPage } from "./PamSessionsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/"
)({
validateSearch: zodValidator(
z.object({
search: z.string().optional()
})
),
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Sessions"
}
]
};
},
component: PamSessionPage
});