mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f84d7c5d3 | ||
|
|
fe251c4e48 | ||
|
|
0dd0f833ff | ||
|
|
33e3287d74 | ||
|
|
039bd2427d | ||
|
|
316bf62ea8 | ||
|
|
857c219f2b | ||
|
|
9dad90c142 | ||
|
|
e3843e7178 | ||
|
|
82011fb417 | ||
|
|
484f05c63c | ||
|
|
fbf87df0ca | ||
|
|
8e35bd0a62 | ||
|
|
3e87ce9514 | ||
|
|
dc8ed0ff06 | ||
|
|
5cef473a2b |
@@ -793,10 +793,6 @@
|
||||
"Removed page restriction": "Removed page restriction",
|
||||
"Added page permission": "Added page permission",
|
||||
"Removed page permission": "Removed page permission",
|
||||
"Changed page permission": "Changed page permission",
|
||||
"Requested password reset": "Requested password reset",
|
||||
"Created template": "Created template",
|
||||
"Deleted template": "Deleted template",
|
||||
"day": "day",
|
||||
"days": "days",
|
||||
"week": "week",
|
||||
@@ -884,9 +880,6 @@
|
||||
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
|
||||
"Page verification expires soon": "Page verification expires soon",
|
||||
"Page verification has expired": "Page verification has expired",
|
||||
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM destination <bold>{{name}}</bold> is failing",
|
||||
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures",
|
||||
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM destination <bold>{{name}}</bold> recovered",
|
||||
"Verifying your email": "Verifying your email",
|
||||
"Please wait...": "Please wait...",
|
||||
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
|
||||
@@ -1344,60 +1337,5 @@
|
||||
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
|
||||
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
|
||||
"Title only": "Title only",
|
||||
"you": "you",
|
||||
"Actions": "Actions",
|
||||
"Add destination": "Add destination",
|
||||
"Are you sure you want to delete the destination": "Are you sure you want to delete the destination",
|
||||
"Audit logs": "Audit logs",
|
||||
"Audit logs & SIEM": "Audit logs & SIEM",
|
||||
"Auth header name": "Auth header name",
|
||||
"Auth header prefix": "Auth header prefix",
|
||||
"Body format": "Body format",
|
||||
"Created SIEM destination": "Created SIEM destination",
|
||||
"Datadog site": "Datadog site",
|
||||
"Defaults to this instance's hostname": "Defaults to this instance's hostname",
|
||||
"Delete destination": "Delete destination",
|
||||
"Deleted SIEM destination": "Deleted SIEM destination",
|
||||
"Destination created": "Destination created",
|
||||
"Destination deleted": "Destination deleted",
|
||||
"Destination updated": "Destination updated",
|
||||
"Disabled": "Disabled",
|
||||
"Edit destination": "Edit destination",
|
||||
"Endpoint URL": "Endpoint URL",
|
||||
"Failing": "Failing",
|
||||
"Failing since {{time}}": "Failing since {{time}}",
|
||||
"HEC token": "HEC token",
|
||||
"HEC URL": "HEC URL",
|
||||
"Healthy": "Healthy",
|
||||
"Hide advanced options": "Hide advanced options",
|
||||
"Host": "Host",
|
||||
"Index": "Index",
|
||||
"Insecure: connections can be intercepted.": "Insecure: connections can be intercepted.",
|
||||
"JSON array": "JSON array",
|
||||
"Last delivered": "Last delivered",
|
||||
"Last error": "Last error",
|
||||
"Leave empty to use the token's default index": "Leave empty to use the token's default index",
|
||||
"Maximum of {{limit}} destinations reached": "Maximum of {{limit}} destinations reached",
|
||||
"Could not load SIEM destinations: {{message}}": "Could not load SIEM destinations: {{message}}",
|
||||
"No destinations yet": "No destinations yet",
|
||||
"Preset": "Preset",
|
||||
"Retry now": "Retry now",
|
||||
"Retry scheduled": "Retry scheduled",
|
||||
"Send test event": "Send test event",
|
||||
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Sent in the auth header below. Leave empty if your receiver does not need one.",
|
||||
"Service": "Service",
|
||||
"Show advanced options": "Show advanced options",
|
||||
"SIEM": "SIEM",
|
||||
"SIEM streaming": "SIEM streaming",
|
||||
"SIEM streaming requires an Enterprise license.": "SIEM streaming requires an Enterprise license.",
|
||||
"Source": "Source",
|
||||
"Sourcetype": "Sourcetype",
|
||||
"Tags": "Tags",
|
||||
"Test connection": "Test connection",
|
||||
"Test event delivered successfully.": "Test event delivered successfully.",
|
||||
"Test the connection before saving.": "Test the connection before saving.",
|
||||
"Test event delivered to {{name}}": "Test event delivered to {{name}}",
|
||||
"Updated SIEM destination": "Updated SIEM destination",
|
||||
"Verify TLS certificate": "Verify TLS certificate",
|
||||
"e.g. Splunk prod": "e.g. Splunk prod"
|
||||
"you": "you"
|
||||
}
|
||||
|
||||
@@ -166,11 +166,6 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"audit/siem"} element={<AuditLogs />} />
|
||||
<Route
|
||||
path={"siem"}
|
||||
element={<Navigate to="/settings/audit/siem" replace />}
|
||||
/>
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
|
||||
@@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "Audit logs & SIEM",
|
||||
label: "Audit log",
|
||||
icon: IconHistory,
|
||||
path: "/settings/audit",
|
||||
feature: Feature.AUDIT_LOGS,
|
||||
@@ -219,7 +219,7 @@ export default function SettingsSidebar() {
|
||||
case "API management":
|
||||
prefetchHandler = prefetchApiKeyManagement;
|
||||
break;
|
||||
case "Audit logs & SIEM":
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
|
||||
@@ -22,7 +22,6 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"user.role_changed": "Changed user role",
|
||||
"user.password_changed": "Changed password",
|
||||
"user.password_reset": "Reset password",
|
||||
"user.password_reset_requested": "Requested password reset",
|
||||
"user.updated": "Updated user",
|
||||
"user.deactivated": "Deactivated user",
|
||||
"user.activated": "Activated user",
|
||||
@@ -63,7 +62,6 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"page.restriction_removed": "Removed page restriction",
|
||||
"page.permission_added": "Added page permission",
|
||||
"page.permission_removed": "Removed page permission",
|
||||
"page.permission_role_changed": "Changed page permission",
|
||||
"page.verification_created": "Created page verification",
|
||||
"page.verification_updated": "Updated page verification",
|
||||
"page.verification_removed": "Removed page verification",
|
||||
@@ -81,13 +79,6 @@ export const auditEventLabels: Record<string, string> = {
|
||||
|
||||
"license.activated": "Activated license",
|
||||
"license.removed": "Removed license",
|
||||
|
||||
"siem_destination.created": "Created SIEM destination",
|
||||
"siem_destination.updated": "Updated SIEM destination",
|
||||
"siem_destination.deleted": "Deleted SIEM destination",
|
||||
|
||||
"template.created": "Created template",
|
||||
"template.deleted": "Deleted template",
|
||||
};
|
||||
|
||||
export function getEventLabel(event: string): string {
|
||||
@@ -114,10 +105,6 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "user.activated", label: "Activated user" },
|
||||
{ value: "user.role_changed", label: "Changed user role" },
|
||||
{ value: "user.password_changed", label: "Changed password" },
|
||||
{
|
||||
value: "user.password_reset_requested",
|
||||
label: "Requested password reset",
|
||||
},
|
||||
{ value: "user.mfa_enabled", label: "Enabled MFA" },
|
||||
{ value: "user.mfa_disabled", label: "Disabled MFA" },
|
||||
],
|
||||
@@ -160,10 +147,6 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||
{ value: "page.permission_added", label: "Added page permission" },
|
||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||
{
|
||||
value: "page.permission_role_changed",
|
||||
label: "Changed page permission",
|
||||
},
|
||||
{ value: "page.verification_created", label: "Created page verification" },
|
||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
||||
@@ -210,19 +193,4 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "license.removed", label: "Removed license" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "SIEM",
|
||||
items: [
|
||||
{ value: "siem_destination.created", label: "Created SIEM destination" },
|
||||
{ value: "siem_destination.updated", label: "Updated SIEM destination" },
|
||||
{ value: "siem_destination.deleted", label: "Deleted SIEM destination" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Template",
|
||||
items: [
|
||||
{ value: "template.created", label: "Created template" },
|
||||
{ value: "template.deleted", label: "Deleted template" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import Paginate from "@/components/common/paginate";
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
|
||||
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
|
||||
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
|
||||
import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
import { DocumentTitle } from "@/components/ui/document-title.tsx";
|
||||
|
||||
@@ -51,8 +48,6 @@ export default function AuditLogs() {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner } = useUserRole();
|
||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [eventFilter, setEventFilter] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -90,8 +85,6 @@ export default function AuditLogs() {
|
||||
|
||||
const { data, isLoading } = useAuditLogsQuery(params);
|
||||
|
||||
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
@@ -101,150 +94,125 @@ export default function AuditLogs() {
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value === "siem") {
|
||||
navigate("/settings/audit/siem");
|
||||
} else {
|
||||
navigate("/settings/audit");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title={t("Audit logs & SIEM")} />
|
||||
<DocumentTitle title={t("Audit log")} />
|
||||
|
||||
<SettingsTitle title={t("Audit logs & SIEM")} />
|
||||
<SettingsTitle title={t("Audit log")} />
|
||||
|
||||
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab fw={500} value="audit">
|
||||
{t("Audit logs")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab fw={500} value="siem">
|
||||
{t("SIEM")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Group mb="md" gap="sm">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={eventFilterOptions.map((group) => ({
|
||||
group: t(group.group),
|
||||
items: group.items.map((item) => ({
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
}))}
|
||||
value={eventFilter}
|
||||
onChange={handleEventChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Tabs.Panel value="audit" pt="md">
|
||||
<Group mb="md" gap="sm">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={eventFilterOptions.map((group) => ({
|
||||
group: t(group.group),
|
||||
items: group.items.map((item) => ({
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
}))}
|
||||
value={eventFilter}
|
||||
onChange={handleEventChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withArrow
|
||||
opened={settingsOpen}
|
||||
onChange={(opened) => {
|
||||
if (!opened) resetRetentionForm();
|
||||
setSettingsOpen(opened);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Audit settings")}>
|
||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||
<IconSettings size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text fz="sm" fw={500} mb={4}>
|
||||
{t("Retention")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{t("Logs older than this period are automatically deleted.")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
onChange={(val) => setRetentionAmount(val)}
|
||||
min={1}
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "days", label: t("days") },
|
||||
{ value: "months", label: t("months") },
|
||||
{ value: "years", label: t("years") },
|
||||
]}
|
||||
value={retentionUnit}
|
||||
onChange={(value) => {
|
||||
if (value === "days" || value === "months" || value === "years") {
|
||||
setRetentionUnit(value);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
resetRetentionForm();
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||
const clamped = Math.max(1, num);
|
||||
setRetentionAmount(clamped);
|
||||
const days = retentionToDays(clamped, retentionUnit);
|
||||
if (days !== currentDays) {
|
||||
updateRetention.mutate({ auditRetentionDays: days });
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
loading={updateRetention.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withArrow
|
||||
opened={settingsOpen}
|
||||
onChange={(opened) => {
|
||||
if (!opened) resetRetentionForm();
|
||||
setSettingsOpen(opened);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Audit settings")}>
|
||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||
<IconSettings size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text fz="sm" fw={500} mb={4}>
|
||||
{t("Retention")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{t("Logs older than this period are automatically deleted.")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
onChange={(val) => setRetentionAmount(val)}
|
||||
min={1}
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "days", label: t("days") },
|
||||
{ value: "months", label: t("months") },
|
||||
{ value: "years", label: t("years") },
|
||||
]}
|
||||
value={retentionUnit}
|
||||
onChange={(value) => {
|
||||
if (value === "days" || value === "months" || value === "years") {
|
||||
setRetentionUnit(value);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
resetRetentionForm();
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||
const clamped = Math.max(1, num);
|
||||
setRetentionAmount(clamped);
|
||||
const days = retentionToDays(clamped, retentionUnit);
|
||||
if (days !== currentDays) {
|
||||
updateRetention.mutate({ auditRetentionDays: days });
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
loading={updateRetention.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||
|
||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||
<Space h="md" />
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="siem" pt="md">
|
||||
<SiemStreamingPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,5 +25,4 @@ export const Feature = {
|
||||
OAUTH: 'oauth',
|
||||
AI_CONTROLS: 'ai:controls',
|
||||
MCP_CONTROLS: 'mcp:controls',
|
||||
SIEM: 'siem',
|
||||
} as const;
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISiemDestination } from "@/ee/siem/types/siem.types";
|
||||
import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query";
|
||||
|
||||
interface DeleteDestinationModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
destination: ISiemDestination | null;
|
||||
}
|
||||
|
||||
export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const deleteMutation = useDeleteSiemDestinationMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!destination) return;
|
||||
await deleteMutation.mutateAsync({ destinationId: destination.id });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Delete destination")}
|
||||
size="md"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("Are you sure you want to delete the destination")}{" "}
|
||||
<strong>{destination?.name}</strong>?
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
|
||||
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types";
|
||||
import {
|
||||
useCreateSiemDestinationMutation,
|
||||
useTestSiemDestinationMutation,
|
||||
useUpdateSiemDestinationMutation,
|
||||
} from "@/ee/siem/queries/siem-query";
|
||||
import {
|
||||
DestinationFormValues,
|
||||
initialValues,
|
||||
toPayload,
|
||||
validateForm,
|
||||
} from "@/ee/siem/lib/destination-form";
|
||||
import { DESTINATION_TYPE_LABELS } from "./destination-table";
|
||||
|
||||
interface DestinationFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
destination?: ISiemDestination | null;
|
||||
}
|
||||
|
||||
function connectionKey(values: DestinationFormValues): string {
|
||||
const { type, config, secrets } = toPayload(values);
|
||||
return JSON.stringify({ type, config, secrets });
|
||||
}
|
||||
|
||||
export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = Boolean(destination);
|
||||
const hasSecrets = destination?.hasSecrets ?? {};
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({
|
||||
result: null,
|
||||
testedPayloadKey: null,
|
||||
});
|
||||
const createMutation = useCreateSiemDestinationMutation();
|
||||
const updateMutation = useUpdateSiemDestinationMutation();
|
||||
const testMutation = useTestSiemDestinationMutation();
|
||||
|
||||
const form = useForm<DestinationFormValues>({
|
||||
initialValues: initialValues(destination),
|
||||
validate: (values) => validateForm(values, hasSecrets),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
form.setValues(initialValues(destination));
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTestState({ result: null, testedPayloadKey: null });
|
||||
setAdvancedOpen(false);
|
||||
}
|
||||
}, [opened, destination?.id]);
|
||||
|
||||
const handleSubmit = async (values: DestinationFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
try {
|
||||
if (destination) {
|
||||
await updateMutation.mutateAsync({
|
||||
destinationId: destination.id,
|
||||
name: payload.name,
|
||||
config: payload.config,
|
||||
secrets: payload.secrets,
|
||||
enabled: payload.enabled,
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (form.validate().hasErrors) return;
|
||||
const payload = toPayload(form.values);
|
||||
const testedPayloadKey = connectionKey(form.values);
|
||||
setTestState((prev) => ({ ...prev, testedPayloadKey }));
|
||||
try {
|
||||
const result = await testMutation.mutateAsync({
|
||||
type: payload.type,
|
||||
config: payload.config,
|
||||
secrets: payload.secrets,
|
||||
destinationId: destination?.id,
|
||||
});
|
||||
setTestState({ result, testedPayloadKey });
|
||||
} catch {
|
||||
setTestState({ result: null, testedPayloadKey });
|
||||
}
|
||||
};
|
||||
|
||||
const type = form.values.type;
|
||||
const showTls = type !== "datadog";
|
||||
const currentPayloadKey = connectionKey(form.values);
|
||||
const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey;
|
||||
const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination));
|
||||
const testPassed = showTestResult && testState.result.delivered;
|
||||
const requiresTest = (!isEdit || connectionChanged) && !testPassed;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t("Edit destination") : t("Add destination")}
|
||||
size="lg"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("Preset")}
|
||||
data={[
|
||||
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
|
||||
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
|
||||
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
disabled={isEdit}
|
||||
{...form.getInputProps("type")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("e.g. Splunk prod")}
|
||||
required
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
{type === "splunk_hec" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("HEC URL")}
|
||||
placeholder="https://splunk.example.com:8088"
|
||||
required
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("HEC token")}
|
||||
required={!hasSecrets.token}
|
||||
{...form.getInputProps("token")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "datadog" && (
|
||||
<>
|
||||
<Select
|
||||
label={t("Datadog site")}
|
||||
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps("site")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
required={!hasSecrets.apiKey}
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("Endpoint URL")}
|
||||
placeholder="https://collector.example.com/docmost"
|
||||
required
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("Token")}
|
||||
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
|
||||
{...form.getInputProps("token")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
|
||||
</Button>
|
||||
|
||||
<Collapse expanded={advancedOpen}>
|
||||
<Stack gap="md">
|
||||
{type === "splunk_hec" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("Index")}
|
||||
description={t("Leave empty to use the token's default index")}
|
||||
{...form.getInputProps("index")}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput label={t("Source")} {...form.getInputProps("source")} />
|
||||
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("Host")}
|
||||
description={t("Defaults to this instance's hostname")}
|
||||
{...form.getInputProps("host")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "datadog" && (
|
||||
<>
|
||||
<TextInput label={t("Service")} {...form.getInputProps("service")} />
|
||||
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<Group grow>
|
||||
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
|
||||
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
|
||||
</Group>
|
||||
<Select
|
||||
label={t("Body format")}
|
||||
data={[
|
||||
{ value: "json", label: t("JSON array") },
|
||||
{ value: "ndjson", label: "NDJSON" },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps("format")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTls && (
|
||||
<>
|
||||
{!isCloud() && (
|
||||
<Switch
|
||||
label={t("Verify TLS certificate")}
|
||||
description={
|
||||
form.values.rejectUnauthorized
|
||||
? undefined
|
||||
: t("Insecure: connections can be intercepted.")
|
||||
}
|
||||
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
{showTestResult && (
|
||||
<Alert
|
||||
color={testState.result.delivered ? "green" : "red"}
|
||||
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
|
||||
>
|
||||
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
|
||||
{t("Test connection")}
|
||||
</Button>
|
||||
{requiresTest && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Test the connection before saving.")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
|
||||
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
|
||||
{isEdit ? t("Save") : t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISiemDestination } from "@/ee/siem/types/siem.types";
|
||||
|
||||
export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!destination.enabled) {
|
||||
return <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
|
||||
}
|
||||
if (destination.status === "failing") {
|
||||
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
|
||||
}
|
||||
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconPlugConnected,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
|
||||
import { DestinationStatusBadge } from "./destination-status-badge";
|
||||
|
||||
export const DESTINATION_TYPE_LABELS: Record<SiemDestinationType, string> = {
|
||||
splunk_hec: "Splunk HEC",
|
||||
datadog: "Datadog",
|
||||
http: "Generic HTTP",
|
||||
};
|
||||
|
||||
interface DestinationTableProps {
|
||||
destinations?: ISiemDestination[];
|
||||
isLoading?: boolean;
|
||||
onEdit: (destination: ISiemDestination) => void;
|
||||
onTest: (destination: ISiemDestination) => void;
|
||||
onRetry: (destination: ISiemDestination) => void;
|
||||
onDelete: (destination: ISiemDestination) => void;
|
||||
onToggle: (destination: ISiemDestination, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function DestinationTable({
|
||||
destinations,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onTest,
|
||||
onRetry,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: DestinationTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("Type")}</Table.Th>
|
||||
<Table.Th>{t("Enabled")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("Last delivered")}</Table.Th>
|
||||
<Table.Th>{t("Last error")}</Table.Th>
|
||||
<Table.Th aria-label={t("Actions")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{destinations && destinations.length > 0 ? (
|
||||
destinations.map((destination) => (
|
||||
<Table.Tr key={destination.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>{destination.name}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={destination.enabled}
|
||||
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
|
||||
aria-label={t("Enabled")}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DestinationStatusBadge destination={destination} />
|
||||
{destination.failingSince &&
|
||||
(destination.status === "failing" ||
|
||||
!destination.enabled) && (
|
||||
<Text
|
||||
fz="xs"
|
||||
c="dimmed"
|
||||
mt={4}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Failing since {{time}}", {
|
||||
time: formattedDate(
|
||||
new Date(destination.failingSince),
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{destination.lastDeliveredAt
|
||||
? timeAgo(new Date(destination.lastDeliveredAt))
|
||||
: t("Never")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{destination.lastError ? (
|
||||
<Tooltip label={destination.lastError} multiline w={320}>
|
||||
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
|
||||
{destination.lastError}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fz="xs" c="dimmed">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("Actions")}>
|
||||
<IconDots size={18} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
|
||||
{t("Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
|
||||
{t("Send test event")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => onRetry(destination)}
|
||||
disabled={!destination.nextAttemptAt}
|
||||
>
|
||||
{t("Retry now")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
!isLoading && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No destinations yet")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Tooltip } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import {
|
||||
ISiemDestination,
|
||||
SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
import {
|
||||
useRetrySiemDestinationMutation,
|
||||
useSiemDestinationsQuery,
|
||||
extractErrorMessage,
|
||||
useTestSiemDestinationMutation,
|
||||
useUpdateSiemDestinationMutation,
|
||||
} from "@/ee/siem/queries/siem-query";
|
||||
import { DestinationTable } from "@/ee/siem/components/destination-table";
|
||||
import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
|
||||
import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
|
||||
|
||||
export default function SiemStreamingPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner } = useUserRole();
|
||||
const hasFeature = useHasFeature(Feature.SIEM);
|
||||
const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
|
||||
const updateMutation = useUpdateSiemDestinationMutation();
|
||||
const retryMutation = useRetrySiemDestinationMutation();
|
||||
const testMutation = useTestSiemDestinationMutation();
|
||||
const [formOpened, setFormOpened] = useState(false);
|
||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||
const [selected, setSelected] = useState<ISiemDestination | null>(null);
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const atDestinationLimit =
|
||||
(data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
|
||||
|
||||
const handleTest = async (destination: ISiemDestination) => {
|
||||
const result = await testMutation
|
||||
.mutateAsync({
|
||||
type: destination.type,
|
||||
config: destination.config as unknown as Record<string, unknown>,
|
||||
destinationId: destination.id,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!result) return;
|
||||
notifications.show({
|
||||
message: result.delivered
|
||||
? t("Test event delivered to {{name}}", { name: destination.name })
|
||||
: result.error,
|
||||
color: result.delivered ? "green" : "red",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasFeature && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
|
||||
{t("SIEM streaming requires an Enterprise license.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Tooltip
|
||||
label={t("Maximum of {{limit}} destinations reached", {
|
||||
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
|
||||
})}
|
||||
disabled={!atDestinationLimit}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setFormOpened(true);
|
||||
}}
|
||||
disabled={!hasFeature || atDestinationLimit}
|
||||
>
|
||||
{t("Add destination")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{isError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
|
||||
{t("Could not load SIEM destinations: {{message}}", {
|
||||
message: extractErrorMessage(error),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFeature && !isError && (
|
||||
<DestinationTable
|
||||
destinations={data}
|
||||
isLoading={isLoading}
|
||||
onEdit={(destination) => {
|
||||
setSelected(destination);
|
||||
setFormOpened(true);
|
||||
}}
|
||||
onTest={handleTest}
|
||||
onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
|
||||
onDelete={(destination) => {
|
||||
setSelected(destination);
|
||||
setDeleteOpened(true);
|
||||
}}
|
||||
onToggle={(destination, enabled) =>
|
||||
updateMutation.mutate({ destinationId: destination.id, enabled })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DestinationFormModal
|
||||
opened={formOpened}
|
||||
onClose={() => setFormOpened(false)}
|
||||
destination={selected}
|
||||
/>
|
||||
<DeleteDestinationModal
|
||||
opened={deleteOpened}
|
||||
onClose={() => setDeleteOpened(false)}
|
||||
destination={selected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import {
|
||||
DATADOG_SITES,
|
||||
ISiemDestination,
|
||||
ISiemDestinationInput,
|
||||
SiemDestinationType,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
|
||||
export type DestinationFormValues = {
|
||||
name: string;
|
||||
type: SiemDestinationType;
|
||||
url: string;
|
||||
token: string;
|
||||
apiKey: string;
|
||||
authHeaderName: string;
|
||||
authHeaderPrefix: string;
|
||||
format: "json" | "ndjson";
|
||||
index: string;
|
||||
source: string;
|
||||
sourcetype: string;
|
||||
host: string;
|
||||
site: string;
|
||||
service: string;
|
||||
tags: string;
|
||||
rejectUnauthorized: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_FORM_VALUES: DestinationFormValues = {
|
||||
name: "",
|
||||
type: "splunk_hec",
|
||||
url: "",
|
||||
token: "",
|
||||
apiKey: "",
|
||||
authHeaderName: "Authorization",
|
||||
authHeaderPrefix: "Bearer ",
|
||||
format: "json",
|
||||
index: "",
|
||||
source: "docmost",
|
||||
sourcetype: "docmost:audit",
|
||||
host: "",
|
||||
site: DATADOG_SITES[0],
|
||||
service: "docmost",
|
||||
tags: "",
|
||||
rejectUnauthorized: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
|
||||
export const SECRET_MASK = "********";
|
||||
|
||||
export function initialValues(
|
||||
destination?: ISiemDestination | null,
|
||||
): DestinationFormValues {
|
||||
if (!destination) return { ...DEFAULT_FORM_VALUES };
|
||||
const config = destination.config as Record<string, any>;
|
||||
const tls = config.tls ?? {};
|
||||
return {
|
||||
...DEFAULT_FORM_VALUES,
|
||||
name: destination.name,
|
||||
type: destination.type,
|
||||
enabled: destination.enabled,
|
||||
token: destination.hasSecrets?.token ? SECRET_MASK : "",
|
||||
apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
|
||||
url: config.url ?? "",
|
||||
authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
|
||||
authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
|
||||
format: config.format ?? "json",
|
||||
index: config.index ?? "",
|
||||
source: config.source ?? DEFAULT_FORM_VALUES.source,
|
||||
sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
|
||||
host: config.host ?? "",
|
||||
site: config.site ?? DEFAULT_FORM_VALUES.site,
|
||||
service: config.service ?? DEFAULT_FORM_VALUES.service,
|
||||
tags: config.tags ?? "",
|
||||
rejectUnauthorized: tls.rejectUnauthorized ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateForm(
|
||||
values: DestinationFormValues,
|
||||
hasSecrets: Record<string, boolean> = {},
|
||||
): Partial<Record<keyof DestinationFormValues, string>> {
|
||||
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
|
||||
|
||||
if (!values.name.trim()) errors.name = "Name is required";
|
||||
|
||||
if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
|
||||
errors.url = "Enter a valid http(s) URL";
|
||||
}
|
||||
|
||||
if (values.type === "splunk_hec") {
|
||||
if (!values.token && !hasSecrets.token) {
|
||||
errors.token = "HEC token is required";
|
||||
}
|
||||
try {
|
||||
const url = new URL(values.url.trim());
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
|
||||
errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (values.type === "datadog") {
|
||||
if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
|
||||
errors.site = "Select a Datadog site";
|
||||
}
|
||||
if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
|
||||
}
|
||||
|
||||
if (values.type === "http") {
|
||||
if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
|
||||
errors.authHeaderName = "Use letters, digits and hyphens only";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function enteredSecret(key: string, value: string): Record<string, string> {
|
||||
const trimmed = value.trim();
|
||||
return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
|
||||
}
|
||||
|
||||
export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
|
||||
const tls = { rejectUnauthorized: values.rejectUnauthorized };
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
let secrets: Record<string, string>;
|
||||
|
||||
switch (values.type) {
|
||||
case "splunk_hec":
|
||||
config = {
|
||||
url: values.url.trim(),
|
||||
index: values.index.trim(),
|
||||
source: values.source.trim() || "docmost",
|
||||
sourcetype: values.sourcetype.trim() || "docmost:audit",
|
||||
host: values.host.trim(),
|
||||
tls,
|
||||
};
|
||||
secrets = enteredSecret("token", values.token);
|
||||
break;
|
||||
case "datadog":
|
||||
config = {
|
||||
site: values.site,
|
||||
service: values.service.trim() || "docmost",
|
||||
tags: values.tags.trim(),
|
||||
};
|
||||
secrets = enteredSecret("apiKey", values.apiKey);
|
||||
break;
|
||||
default:
|
||||
config = {
|
||||
url: values.url.trim(),
|
||||
authHeaderName: values.authHeaderName.trim(),
|
||||
authHeaderPrefix: values.authHeaderPrefix,
|
||||
format: values.format,
|
||||
tls,
|
||||
};
|
||||
secrets = enteredSecret("token", values.token);
|
||||
}
|
||||
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
config,
|
||||
secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
|
||||
enabled: values.enabled,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createSiemDestination,
|
||||
deleteSiemDestination,
|
||||
getSiemDestinations,
|
||||
retrySiemDestination,
|
||||
testSiemDestination,
|
||||
updateSiemDestination,
|
||||
} from "@/ee/siem/services/siem-service";
|
||||
import {
|
||||
ISiemDestination,
|
||||
ISiemDestinationInput,
|
||||
ISiemTestResult,
|
||||
ITestSiemDestinationInput,
|
||||
IUpdateSiemDestinationInput,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
|
||||
export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
|
||||
|
||||
export function extractErrorMessage(error: Error): string {
|
||||
const data = (error as any)?.response?.data;
|
||||
const message = data?.message ?? error.message;
|
||||
return Array.isArray(message) ? message.join(", ") : String(message);
|
||||
}
|
||||
|
||||
function showError(error: Error) {
|
||||
notifications.show({ message: extractErrorMessage(error), color: "red" });
|
||||
}
|
||||
|
||||
function isForbidden(error: unknown): boolean {
|
||||
return (error as { response?: { status?: number } })?.response?.status === 403;
|
||||
}
|
||||
|
||||
export function useSiemDestinationsQuery(
|
||||
enabled = true,
|
||||
): UseQueryResult<ISiemDestination[], Error> {
|
||||
return useQuery({
|
||||
queryKey: SIEM_DESTINATIONS_KEY,
|
||||
queryFn: getSiemDestinations,
|
||||
enabled,
|
||||
retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateDestinations() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
|
||||
}
|
||||
|
||||
export function useCreateSiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<ISiemDestination, Error, ISiemDestinationInput>({
|
||||
mutationFn: createSiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Destination created") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
|
||||
mutationFn: updateSiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Destination updated") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<void, Error, { destinationId: string }>({
|
||||
mutationFn: deleteSiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Destination deleted") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetrySiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<void, Error, { destinationId: string }>({
|
||||
mutationFn: retrySiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Retry scheduled") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSiemDestinationMutation() {
|
||||
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
|
||||
mutationFn: testSiemDestination,
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
ISiemDestination,
|
||||
ISiemDestinationInput,
|
||||
ISiemTestResult,
|
||||
ITestSiemDestinationInput,
|
||||
IUpdateSiemDestinationInput,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
|
||||
export async function getSiemDestinations(): Promise<ISiemDestination[]> {
|
||||
const req = await api.post<ISiemDestination[]>("/siem/destinations");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createSiemDestination(
|
||||
data: ISiemDestinationInput,
|
||||
): Promise<ISiemDestination> {
|
||||
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateSiemDestination(
|
||||
data: IUpdateSiemDestinationInput,
|
||||
): Promise<ISiemDestination> {
|
||||
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteSiemDestination(data: {
|
||||
destinationId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/siem/destinations/delete", data);
|
||||
}
|
||||
|
||||
export async function testSiemDestination(
|
||||
data: ITestSiemDestinationInput,
|
||||
): Promise<ISiemTestResult> {
|
||||
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function retrySiemDestination(data: {
|
||||
destinationId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/siem/destinations/retry", data);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
|
||||
|
||||
export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
|
||||
export type SiemDestinationStatus = "healthy" | "failing";
|
||||
|
||||
export const DATADOG_SITES = [
|
||||
"datadoghq.com",
|
||||
"datadoghq.eu",
|
||||
"us3.datadoghq.com",
|
||||
"us5.datadoghq.com",
|
||||
"ap1.datadoghq.com",
|
||||
"ddog-gov.com",
|
||||
] as const;
|
||||
|
||||
export interface ITlsOptions {
|
||||
rejectUnauthorized: boolean;
|
||||
}
|
||||
|
||||
export interface IHttpConfig {
|
||||
url: string;
|
||||
authHeaderName: string;
|
||||
authHeaderPrefix: string;
|
||||
format: "json" | "ndjson";
|
||||
tls?: ITlsOptions;
|
||||
}
|
||||
|
||||
export interface ISplunkHecConfig {
|
||||
url: string;
|
||||
index?: string;
|
||||
source: string;
|
||||
sourcetype: string;
|
||||
host?: string;
|
||||
channelId: string;
|
||||
tls?: ITlsOptions;
|
||||
}
|
||||
|
||||
export interface IDatadogConfig {
|
||||
site: string;
|
||||
service: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
|
||||
|
||||
export interface ISiemDestination {
|
||||
id: string;
|
||||
name: string;
|
||||
type: SiemDestinationType;
|
||||
enabled: boolean;
|
||||
status: SiemDestinationStatus;
|
||||
config: ISiemConfig;
|
||||
hasSecrets: Record<string, boolean>;
|
||||
cursorCreatedAt: string;
|
||||
lastDeliveredAt: string | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: string | null;
|
||||
consecutiveFailures: number;
|
||||
nextAttemptAt: string | null;
|
||||
failingSince: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISiemDestinationInput {
|
||||
name: string;
|
||||
type: SiemDestinationType;
|
||||
config: Record<string, unknown>;
|
||||
secrets?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IUpdateSiemDestinationInput {
|
||||
destinationId: string;
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
secrets?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ITestSiemDestinationInput {
|
||||
type: SiemDestinationType;
|
||||
config: Record<string, unknown>;
|
||||
secrets?: Record<string, string>;
|
||||
destinationId?: string;
|
||||
}
|
||||
|
||||
export interface ISiemTestResult {
|
||||
delivered: boolean;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
@@ -63,12 +63,6 @@ export function NotificationItem({
|
||||
return "Page verification expires soon";
|
||||
case "page.verification_expired":
|
||||
return "Page verification has expired";
|
||||
case "siem_destination.failing":
|
||||
return "SIEM destination <bold>{{name}}</bold> is failing";
|
||||
case "siem_destination.disabled":
|
||||
return "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures";
|
||||
case "siem_destination.recovered":
|
||||
return "SIEM destination <bold>{{name}}</bold> recovered";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -83,19 +77,6 @@ export function NotificationItem({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const isSiemDestination = notification.type.startsWith("siem_destination.");
|
||||
const destinationName =
|
||||
typeof notification.data?.destinationName === "string"
|
||||
? notification.data.destinationName
|
||||
: "";
|
||||
const lastError =
|
||||
(notification.type === "siem_destination.failing" ||
|
||||
notification.type === "siem_destination.disabled") &&
|
||||
typeof notification.data?.lastError === "string"
|
||||
? notification.data.lastError
|
||||
: null;
|
||||
const linkUrl = isSiemDestination ? "/settings/audit/siem" : pageUrl;
|
||||
|
||||
const markReadIfNeeded = () => {
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
@@ -116,7 +97,7 @@ export function NotificationItem({
|
||||
return (
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={linkUrl ?? ""}
|
||||
to={pageUrl ?? ""}
|
||||
onClick={handleClick}
|
||||
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
|
||||
// so that right-click (button 2, context menu) does not mark as read
|
||||
@@ -143,21 +124,11 @@ export function NotificationItem({
|
||||
<Text size="sm" lineClamp={2}>
|
||||
<Trans
|
||||
i18nKey={getNotificationMessageKey()}
|
||||
values={{
|
||||
name: isSiemDestination
|
||||
? destinationName
|
||||
: notification.actor?.name,
|
||||
}}
|
||||
values={{ name: notification.actor?.name }}
|
||||
components={{ bold: <Text span fw={600} /> }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{lastError && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
|
||||
{lastError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{notification.page && (
|
||||
<Group gap={4} mt={2} wrap="nowrap">
|
||||
{notification.page.icon ? (
|
||||
|
||||
@@ -9,10 +9,7 @@ export type NotificationType =
|
||||
| "page.verification_expired"
|
||||
| "page.verified"
|
||||
| "page.approval_requested"
|
||||
| "page.approval_rejected"
|
||||
| "siem_destination.failing"
|
||||
| "siem_destination.disabled"
|
||||
| "siem_destination.recovered";
|
||||
| "page.approval_rejected";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:integration": "jest --config test/jest-integration.json --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
@@ -161,8 +162,7 @@
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts",
|
||||
"tsx"
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
@@ -182,7 +182,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
|
||||
@@ -28,7 +28,6 @@ import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { OutboundModule } from './integrations/outbound/outbound.module';
|
||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
@@ -52,7 +51,7 @@ try {
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]),
|
||||
NoopAuditModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
@@ -99,7 +98,6 @@ try {
|
||||
SecurityModule,
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
OutboundModule,
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -14,7 +14,6 @@ export const AuditEvent = {
|
||||
USER_ROLE_CHANGED: 'user.role_changed',
|
||||
USER_PASSWORD_CHANGED: 'user.password_changed',
|
||||
USER_PASSWORD_RESET: 'user.password_reset',
|
||||
USER_PASSWORD_RESET_REQUESTED: 'user.password_reset_requested',
|
||||
USER_UPDATED: 'user.updated',
|
||||
USER_DEACTIVATED: 'user.deactivated',
|
||||
USER_ACTIVATED: 'user.activated',
|
||||
@@ -70,7 +69,6 @@ export const AuditEvent = {
|
||||
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
|
||||
PAGE_PERMISSION_ADDED: 'page.permission_added',
|
||||
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
|
||||
PAGE_PERMISSION_ROLE_CHANGED: 'page.permission_role_changed',
|
||||
// Page verification
|
||||
PAGE_VERIFICATION_CREATED: 'page.verification_created',
|
||||
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
|
||||
@@ -106,16 +104,6 @@ export const AuditEvent = {
|
||||
// Attachment
|
||||
ATTACHMENT_UPLOADED: 'attachment.uploaded',
|
||||
// ATTACHMENT_DELETED: 'attachment.deleted',
|
||||
|
||||
// SIEM streaming
|
||||
SIEM_DESTINATION_CREATED: 'siem_destination.created',
|
||||
SIEM_DESTINATION_UPDATED: 'siem_destination.updated',
|
||||
SIEM_DESTINATION_DELETED: 'siem_destination.deleted',
|
||||
SIEM_DESTINATION_TEST: 'siem_destination.test',
|
||||
|
||||
// Template
|
||||
TEMPLATE_CREATED: 'template.created',
|
||||
TEMPLATE_DELETED: 'template.deleted',
|
||||
} as const;
|
||||
|
||||
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
|
||||
@@ -128,8 +116,7 @@ export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
|
||||
AuditEvent.COMMENT_UPDATED,
|
||||
AuditEvent.COMMENT_RESOLVED,
|
||||
AuditEvent.COMMENT_REOPENED,
|
||||
AuditEvent.ATTACHMENT_UPLOADED,
|
||||
AuditEvent.SIEM_DESTINATION_TEST,
|
||||
AuditEvent.ATTACHMENT_UPLOADED
|
||||
]);
|
||||
|
||||
export const AuditResource = {
|
||||
@@ -149,8 +136,6 @@ export const AuditResource = {
|
||||
WORKSPACE_INVITATION: 'workspace_invitation',
|
||||
ATTACHMENT: 'attachment',
|
||||
LICENSE: 'license',
|
||||
SIEM_DESTINATION: 'siem_destination',
|
||||
TEMPLATE: 'template',
|
||||
} as const;
|
||||
|
||||
export type AuditResourceType =
|
||||
|
||||
@@ -26,7 +26,6 @@ export const Feature = {
|
||||
OAUTH: 'oauth',
|
||||
AI_CONTROLS: 'ai:controls',
|
||||
MCP_CONTROLS: 'mcp:controls',
|
||||
SIEM: 'siem',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -4,7 +4,6 @@ export const CacheKey = {
|
||||
`perm:space-roles:${userId}:${spaceId}`,
|
||||
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
|
||||
`perm:can-edit:${userId}:${pageId}`,
|
||||
SIEM_LICENSED: (workspaceId: string) => `siem:licensed:${workspaceId}`,
|
||||
};
|
||||
|
||||
// Permission caches dedupe repeated checks within and across short request bursts.
|
||||
|
||||
@@ -219,13 +219,6 @@ export class AuthService {
|
||||
subject: 'Reset your password',
|
||||
template: emailTemplate,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_RESET_REQUESTED,
|
||||
resourceType: AuditResource.USER,
|
||||
resourceId: user.id,
|
||||
metadata: { source: 'forgot_password' },
|
||||
});
|
||||
}
|
||||
|
||||
async passwordReset(
|
||||
|
||||
@@ -10,9 +10,6 @@ export const NotificationType = {
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
SIEM_DESTINATION_FAILING: 'siem_destination.failing',
|
||||
SIEM_DESTINATION_DISABLED: 'siem_destination.disabled',
|
||||
SIEM_DESTINATION_RECOVERED: 'siem_destination.recovered',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
@@ -43,9 +40,6 @@ export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
NotificationType.PAGE_PERMISSION_GRANTED,
|
||||
NotificationType.SIEM_DESTINATION_FAILING,
|
||||
NotificationType.SIEM_DESTINATION_DISABLED,
|
||||
NotificationType.SIEM_DESTINATION_RECOVERED,
|
||||
];
|
||||
|
||||
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
|
||||
|
||||
@@ -55,6 +55,10 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -129,24 +133,27 @@ export class PageService {
|
||||
ydoc = createYdocFromJson(prosemirrorJson);
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.insertPage({
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
position: await this.nextPagePosition(
|
||||
createPageDto.spaceId,
|
||||
parentPageId,
|
||||
),
|
||||
icon: createPageDto.icon,
|
||||
parentPageId: parentPageId,
|
||||
spaceId: createPageDto.spaceId,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
isBase,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
}, trx);
|
||||
const page = await this.pageRepo.insertPage(
|
||||
{
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
position: await this.nextPagePosition(
|
||||
createPageDto.spaceId,
|
||||
parentPageId,
|
||||
),
|
||||
icon: createPageDto.icon,
|
||||
parentPageId: parentPageId,
|
||||
spaceId: createPageDto.spaceId,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
isBase,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
if (trx) {
|
||||
// Add the watcher inside the caller's transaction so the async worker
|
||||
@@ -867,6 +874,9 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
sql<number>`0`.as('traversalDepth'),
|
||||
])
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
@@ -883,13 +893,28 @@ export class PageService {
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.deletedAt',
|
||||
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
|
||||
sql<number>`pa.traversal_depth + 1`.as('traversalDepth'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pa.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll('page_ancestors')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'isBase',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'deletedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.select((eb) =>
|
||||
eb
|
||||
.exists(
|
||||
@@ -901,9 +926,12 @@ export class PageService {
|
||||
)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
.orderBy('traversalDepth', 'desc')
|
||||
.execute();
|
||||
|
||||
return ancestors.reverse();
|
||||
assertAcyclicPageTraversal(ancestors, childPageId);
|
||||
|
||||
return ancestors.map(stripPageTraversalMetadata);
|
||||
}
|
||||
|
||||
async getRecentSpacePages(
|
||||
@@ -1009,19 +1037,29 @@ export class PageService {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(descendants, pageId);
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
// Queue attachment deletion for all pages with unique job IDs to prevent duplicates
|
||||
|
||||
@@ -5,6 +5,11 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
PageHierarchyCycleError,
|
||||
} from '../../../database/helpers/page-hierarchy-cycle';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
@@ -42,17 +47,35 @@ export class TrashCleanupService {
|
||||
.select(['id'])
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.where('deletedAt', '<', retentionDate)
|
||||
.orderBy('id')
|
||||
.execute();
|
||||
|
||||
for (const page of oldDeletedPages) {
|
||||
let pageIds: string[];
|
||||
|
||||
try {
|
||||
await this.cleanupPage(page.id);
|
||||
totalCleaned++;
|
||||
const ancestors = await this.getPageAncestors(page.id);
|
||||
assertAcyclicPageTraversal(ancestors, page.id);
|
||||
|
||||
const descendants = await this.getPageDescendants(page.id);
|
||||
assertAcyclicPageTraversal(descendants, page.id);
|
||||
pageIds = descendants.map((descendant) => descendant.id);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
if (error instanceof PageHierarchyCycleError) {
|
||||
this.logCleanupError(page.id, error);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (pageIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
totalCleaned += await this.cleanupPage(page.id, pageIds);
|
||||
} catch (error) {
|
||||
this.logCleanupError(page.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,27 +93,66 @@ export class TrashCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupPage(pageId: string) {
|
||||
// Get all descendants using recursive CTE (including the page itself)
|
||||
const descendants = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
private async getPageAncestors(pageId: string) {
|
||||
return this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.select([
|
||||
'id',
|
||||
'parentPageId',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
.select([
|
||||
'p.id',
|
||||
'p.parentPageId',
|
||||
sql<string[]>`pa.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pa.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
.where('pa.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async getPageDescendants(pageId: string) {
|
||||
// Get all descendants using recursive CTE (including the page itself)
|
||||
return this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
private async cleanupPage(pageId: string, pageIds: string[]) {
|
||||
this.logger.debug(
|
||||
`Cleaning up page ${pageId} with ${pageIds.length - 1} descendants`,
|
||||
);
|
||||
@@ -114,14 +176,24 @@ export class TrashCleanupService {
|
||||
}
|
||||
|
||||
try {
|
||||
if (pageIds.length > 0) {
|
||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||
}
|
||||
const result = await this.db
|
||||
.deleteFrom('pages')
|
||||
.where('id', 'in', pageIds)
|
||||
.executeTakeFirst();
|
||||
return Number(result.numDeletedRows);
|
||||
} catch (error) {
|
||||
// Log but don't throw - pages might have been deleted by another node
|
||||
this.logger.warn(
|
||||
`Error deleting pages, they may have been already deleted: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private logCleanupError(pageId: string, error: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to cleanup page ${pageId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { validate as isValidUUID } from 'uuid';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../page/transclusion/transclusion.service';
|
||||
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
|
||||
import { stripPageTraversalMetadata } from '../../database/helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -144,7 +145,7 @@ export class ShareService {
|
||||
|
||||
async getShareForPage(pageId: string, workspaceId: string) {
|
||||
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
||||
const share = await this.db
|
||||
const traversal = await this.db
|
||||
.withRecursive('page_hierarchy', (cte) =>
|
||||
cte
|
||||
.selectFrom('pages')
|
||||
@@ -164,41 +165,67 @@ export class ShareService {
|
||||
'shares.spaceId',
|
||||
'shares.workspaceId',
|
||||
'shares.createdAt',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll(
|
||||
(union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
's.createdAt',
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||
.where(sql`ph.level`, '<', sql`25`), // prevent loop
|
||||
.unionAll((union) =>
|
||||
union
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
|
||||
.leftJoin('shares as s', 's.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.parentPageId',
|
||||
sql`ph.level + 1`.as('level'),
|
||||
's.id as shareId',
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
's.createdAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where(sql`ph.share_id`, 'is', null) // stop if share found
|
||||
.where('ph.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.where('shareId', 'is not', null)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'parentPageId',
|
||||
'level',
|
||||
'shareId',
|
||||
'shareKey',
|
||||
'includeSubPages',
|
||||
'searchIndexing',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'isCycle',
|
||||
])
|
||||
.execute();
|
||||
|
||||
if (traversal.some((row) => row.isCycle)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchedShare = traversal.find((row) => row.shareId !== null);
|
||||
const share = matchedShare
|
||||
? stripPageTraversalMetadata(matchedShare)
|
||||
: undefined;
|
||||
|
||||
if (!share || share.workspaceId !== workspaceId) {
|
||||
return undefined;
|
||||
@@ -228,67 +255,6 @@ export class ShareService {
|
||||
};
|
||||
}
|
||||
|
||||
async getShareAncestorPage(
|
||||
ancestorPageId: string,
|
||||
childPageId: string,
|
||||
): Promise<any> {
|
||||
let ancestor = null;
|
||||
try {
|
||||
ancestor = await this.db
|
||||
.withRecursive('page_ancestors', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
(eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.ref('id'), '=', ancestorPageId)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.where(isValidUUID(childPageId) ? 'id' : 'slugId', '=', childPageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
(eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when(eb.ref('p.id'), '=', ancestorPageId)
|
||||
.then(true)
|
||||
.else(false)
|
||||
.end()
|
||||
.as('found'),
|
||||
])
|
||||
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
|
||||
// Continue recursing only when the target ancestor hasn't been found on that branch.
|
||||
.where('pa.found', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll()
|
||||
.where('found', '=', true)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
} catch (err) {
|
||||
// empty
|
||||
}
|
||||
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve transclusion content for a public share viewer. Each requested
|
||||
* source page must itself be reachable via the share graph (its own share
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
PageHierarchyCycleError,
|
||||
stripPageTraversalMetadata,
|
||||
} from './page-hierarchy-cycle';
|
||||
|
||||
describe('page hierarchy cycle contract', () => {
|
||||
it('does nothing when no row is marked as a cycle', () => {
|
||||
expect(() =>
|
||||
assertAcyclicPageTraversal(
|
||||
[
|
||||
{ id: 'page-1', title: 'Root', isCycle: false },
|
||||
{ id: 'page-2', title: 'Child', isCycle: false },
|
||||
],
|
||||
'page-1',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws PageHierarchyCycleError when a row is marked as a cycle', () => {
|
||||
expect(() =>
|
||||
assertAcyclicPageTraversal(
|
||||
[{ id: 'page-1', title: 'Root', isCycle: true }],
|
||||
'page-1',
|
||||
),
|
||||
).toThrow(PageHierarchyCycleError);
|
||||
});
|
||||
|
||||
it('keeps the root page id on the error for safe logging', () => {
|
||||
try {
|
||||
assertAcyclicPageTraversal(
|
||||
[{ id: 'page-1', title: 'Root', isCycle: true }],
|
||||
'root-page',
|
||||
);
|
||||
throw new Error('expected a cycle error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PageHierarchyCycleError);
|
||||
expect((error as PageHierarchyCycleError).rootPageId).toBe('root-page');
|
||||
expect((error as PageHierarchyCycleError).code).toBe(
|
||||
'PAGE_HIERARCHY_CYCLE',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes traversal metadata without changing the public row fields', () => {
|
||||
const row = { id: 'page-1', title: 'Root', isCycle: false };
|
||||
|
||||
expect(stripPageTraversalMetadata(row)).toEqual({
|
||||
id: 'page-1',
|
||||
title: 'Root',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export type CycleTrackedRow = {
|
||||
isCycle: boolean;
|
||||
};
|
||||
|
||||
export class PageHierarchyCycleError extends Error {
|
||||
readonly code = 'PAGE_HIERARCHY_CYCLE';
|
||||
|
||||
constructor(readonly rootPageId: string) {
|
||||
super('Cyclic page hierarchy detected');
|
||||
this.name = 'PageHierarchyCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAcyclicPageTraversal<T extends CycleTrackedRow>(
|
||||
rows: readonly T[],
|
||||
rootPageId: string,
|
||||
): void {
|
||||
if (rows.some((row) => row.isCycle)) {
|
||||
throw new PageHierarchyCycleError(rootPageId);
|
||||
}
|
||||
}
|
||||
|
||||
export function stripPageTraversalMetadata<T extends CycleTrackedRow>(
|
||||
row: T,
|
||||
): Omit<T, 'isCycle'> {
|
||||
const { isCycle: _isCycle, ...publicRow } = row;
|
||||
return publicRow;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('siem_destinations')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('config', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('secrets', 'text', (col) => col.notNull())
|
||||
.addColumn('cursor_created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('cursor_id', 'uuid', (col) =>
|
||||
col.notNull().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('cursor_snapshot', 'text')
|
||||
// Fences cursor writes from stale jobs after configuration changes.
|
||||
.addColumn('version', 'integer', (col) => col.notNull().defaultTo(0))
|
||||
.addColumn('status', 'varchar', (col) => col.notNull().defaultTo('healthy'))
|
||||
.addColumn('consecutive_failures', 'integer', (col) =>
|
||||
col.notNull().defaultTo(0),
|
||||
)
|
||||
.addColumn('next_attempt_at', 'timestamptz')
|
||||
.addColumn('last_delivered_at', 'timestamptz')
|
||||
.addColumn('last_error', 'text')
|
||||
.addColumn('last_error_at', 'timestamptz')
|
||||
.addColumn('failing_since', 'timestamptz')
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_siem_destinations_workspace_id')
|
||||
.ifNotExists()
|
||||
.on('siem_destinations')
|
||||
.columns(['workspace_id'])
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_siem_destinations_due
|
||||
ON siem_destinations (next_attempt_at)
|
||||
WHERE enabled = true
|
||||
`.execute(db);
|
||||
|
||||
await db.schema.alterTable('audit').addColumn('user_agent', 'text').execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('audit').dropColumn('user_agent').execute();
|
||||
await db.schema.dropTable('siem_destinations').ifExists().execute();
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
import { assertAcyclicPageTraversal } from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
export { PagePermissionMember } from './types/page-permission.types';
|
||||
|
||||
@@ -332,7 +333,7 @@ export class PagePermissionRepo {
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return this.db
|
||||
const ancestors = await this.db
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
@@ -340,6 +341,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -350,19 +353,41 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
]),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
'pageAccess.id as pageAccessId',
|
||||
'pageAccess.pageId',
|
||||
'pageAccess.accessLevel',
|
||||
'ancestors.depth',
|
||||
'ancestors.isCycle',
|
||||
])
|
||||
.orderBy('ancestors.depth', 'asc')
|
||||
.executeTakeFirst();
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(ancestors, pageId);
|
||||
|
||||
const restrictedAncestor = ancestors.find(
|
||||
(ancestor) => ancestor.pageAccessId !== null,
|
||||
);
|
||||
if (!restrictedAncestor) return undefined;
|
||||
|
||||
return {
|
||||
pageAccessId: restrictedAncestor.pageAccessId,
|
||||
pageId: restrictedAncestor.pageId,
|
||||
accessLevel: restrictedAncestor.accessLevel,
|
||||
depth: restrictedAncestor.depth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,17 +421,30 @@ export class PagePermissionRepo {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
hasHierarchyCycle: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
0 AS depth,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.depth + 1,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT
|
||||
(SELECT bool_or(is_cycle) FROM ancestors) AS "hasHierarchyCycle",
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
@@ -422,6 +460,13 @@ export class PagePermissionRepo {
|
||||
`.execute(this.db);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (row?.hasHierarchyCycle) {
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: false,
|
||||
canEdit: false,
|
||||
};
|
||||
}
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
@@ -461,6 +506,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
@@ -471,7 +518,14 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`ancestors.depth + 1`.as('depth'),
|
||||
]),
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -511,6 +565,14 @@ export class PagePermissionRepo {
|
||||
.else(false)
|
||||
.end()
|
||||
.as('hasInheritedRestriction'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('ancestors')
|
||||
.select('ancestors.ancestorId')
|
||||
.where('ancestors.isCycle', '=', true),
|
||||
)
|
||||
.as('hasHierarchyCycle'),
|
||||
// canAccess: no restricted ancestor without ANY permission
|
||||
eb
|
||||
.case()
|
||||
@@ -638,13 +700,15 @@ export class PagePermissionRepo {
|
||||
|
||||
const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
|
||||
const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
|
||||
const hasHierarchyCycle = Boolean(result?.hasHierarchyCycle);
|
||||
|
||||
return {
|
||||
hasDirectRestriction,
|
||||
hasInheritedRestriction,
|
||||
hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
|
||||
canAccess: Boolean(result?.canAccess),
|
||||
canEdit: Boolean(result?.canEdit),
|
||||
hasAnyRestriction:
|
||||
hasDirectRestriction || hasInheritedRestriction || hasHierarchyCycle,
|
||||
canAccess: !hasHierarchyCycle && Boolean(result?.canAccess),
|
||||
canEdit: !hasHierarchyCycle && Boolean(result?.canEdit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -664,7 +728,48 @@ export class PagePermissionRepo {
|
||||
if (spaceId) {
|
||||
const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId);
|
||||
if (!hasRestrictions) {
|
||||
return pageIds;
|
||||
const cyclicPages = await this.db
|
||||
.withRecursive('allAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'allAncestors',
|
||||
'allAncestors.parentPageId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('allAncestors')
|
||||
.select('allAncestors.pageId')
|
||||
.distinct()
|
||||
.where('allAncestors.isCycle', '=', true)
|
||||
.execute();
|
||||
const cyclicPageIds = new Set(cyclicPages.map((page) => page.pageId));
|
||||
|
||||
return pageIds.filter((pageId) => !cyclicPageIds.has(pageId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,6 +781,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -690,12 +797,29 @@ export class PagePermissionRepo {
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
]),
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
.select('pages.id')
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -745,6 +869,8 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`0`.as('depth'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.unionAll((eb) =>
|
||||
@@ -760,7 +886,14 @@ export class PagePermissionRepo {
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`all_ancestors.depth + 1`.as('depth'),
|
||||
]),
|
||||
sql<string[]>`all_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(all_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('allAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages')
|
||||
@@ -821,6 +954,16 @@ export class PagePermissionRepo {
|
||||
.as('canEdit'),
|
||||
)
|
||||
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('allAncestors')
|
||||
.select('allAncestors.ancestorId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('allAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
// view filter: no restricted ancestor without any permission
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
@@ -865,21 +1008,39 @@ export class PagePermissionRepo {
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId']),
|
||||
.select([
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<string[]>`ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('ancestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select('pageAccess.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.select([
|
||||
sql<boolean>`bool_or(ancestors.is_cycle)`.as('hasHierarchyCycle'),
|
||||
sql<boolean>`bool_or(page_access.id IS NOT NULL)`.as('hasPageAccess'),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
return !!result;
|
||||
return Boolean(result?.hasHierarchyCycle || result?.hasPageAccess);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,6 +1082,8 @@ export class PagePermissionRepo {
|
||||
'child.id as childId',
|
||||
'child.id as ancestorId',
|
||||
'child.parentPageId as ancestorParentId',
|
||||
sql<string[]>`ARRAY[child.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
@@ -936,7 +1099,14 @@ export class PagePermissionRepo {
|
||||
'childAncestors.childId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
]),
|
||||
sql<string[]>`child_ancestors.traversal_path || pages.id`.as(
|
||||
'traversalPath',
|
||||
),
|
||||
sql<boolean>`pages.id = ANY(child_ancestors.traversal_path)`.as(
|
||||
'isCycle',
|
||||
),
|
||||
])
|
||||
.where('childAncestors.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('pages as child')
|
||||
@@ -944,6 +1114,16 @@ export class PagePermissionRepo {
|
||||
.distinct()
|
||||
.where('child.parentPageId', 'in', parentIds)
|
||||
.where('child.deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('childAncestors')
|
||||
.select('childAncestors.ancestorId')
|
||||
.whereRef('childAncestors.childId', '=', 'child.id')
|
||||
.where('childAncestors.isCycle', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
@@ -978,67 +1158,6 @@ export class PagePermissionRepo {
|
||||
return results.map((r) => r.parentPageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
|
||||
* Used to filter pages from public shares - if a page is restricted, it and all its
|
||||
* children should be hidden.
|
||||
*/
|
||||
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.withRecursive('descendants', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', rootPageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendants',
|
||||
'descendants.descendantId',
|
||||
'pages.parentPageId',
|
||||
)
|
||||
.select(['pages.id as descendantId', 'pages.parentPageId'])
|
||||
.where('pages.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.withRecursive('descendantAncestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('descendants')
|
||||
.innerJoin('pages', 'pages.id', 'descendants.descendantId')
|
||||
.select([
|
||||
'descendants.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
])
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin(
|
||||
'descendantAncestors',
|
||||
'descendantAncestors.ancestorParentId',
|
||||
'pages.id',
|
||||
)
|
||||
.select([
|
||||
'descendantAncestors.descendantId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId as ancestorParentId',
|
||||
]),
|
||||
),
|
||||
)
|
||||
.selectFrom('descendantAncestors')
|
||||
.innerJoin(
|
||||
'pageAccess',
|
||||
'pageAccess.pageId',
|
||||
'descendantAncestors.ancestorId',
|
||||
)
|
||||
.select('descendantAncestors.descendantId')
|
||||
.distinct()
|
||||
.execute();
|
||||
|
||||
return results.map((r) => r.descendantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a pageId and a set of candidate userIds, return the subset who can
|
||||
* access the page (have permission on ALL restricted ancestors).
|
||||
@@ -1052,17 +1171,27 @@ export class PagePermissionRepo {
|
||||
|
||||
const results = await sql<{ userId: string }>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id
|
||||
SELECT
|
||||
id AS ancestor_id,
|
||||
parent_page_id,
|
||||
ARRAY[id]::uuid[] AS traversal_path,
|
||||
false AS is_cycle
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id
|
||||
SELECT
|
||||
p.id,
|
||||
p.parent_page_id,
|
||||
a.traversal_path || p.id,
|
||||
p.id = ANY(a.traversal_path) AS is_cycle
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
WHERE NOT a.is_cycle
|
||||
)
|
||||
SELECT cu.user_id AS "userId"
|
||||
FROM unnest(${userIds}::uuid[]) AS cu(user_id)
|
||||
WHERE NOT EXISTS (
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ancestors WHERE is_cycle)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
|
||||
@@ -16,6 +16,10 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import {
|
||||
assertAcyclicPageTraversal,
|
||||
stripPageTraversalMetadata,
|
||||
} from '../../helpers/page-hierarchy-cycle';
|
||||
|
||||
@Injectable()
|
||||
export class PageRepo {
|
||||
@@ -203,21 +207,31 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('pd.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(descendants, pageId);
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
@@ -272,19 +286,29 @@ export class PageRepo {
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.select([
|
||||
'id',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
|
||||
.select([
|
||||
'p.id',
|
||||
sql<string[]>`pd.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(pd.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
.where('pd.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.select(['id', 'isCycle'])
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, pageId);
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
|
||||
// Restore all pages, but only detach the root page if its parent is deleted
|
||||
@@ -354,7 +378,12 @@ export class PageRepo {
|
||||
});
|
||||
}
|
||||
|
||||
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
|
||||
async getCreatedByPages(
|
||||
creatorId: string,
|
||||
requestingUserId: string,
|
||||
pagination: PaginationOptions,
|
||||
spaceId?: string,
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
@@ -365,7 +394,11 @@ export class PageRepo {
|
||||
if (spaceId) {
|
||||
query = query.where('spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
|
||||
query = query.where(
|
||||
'spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId),
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
@@ -491,7 +524,7 @@ export class PageRepo {
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
return this.db
|
||||
const pages = await this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
@@ -506,6 +539,8 @@ export class PageRepo {
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
sql<string[]>`ARRAY[id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('id', '=', parentPageId)
|
||||
@@ -524,15 +559,34 @@ export class PageRepo {
|
||||
'p.workspaceId',
|
||||
'p.createdAt',
|
||||
'p.updatedAt',
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('ph.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages.map((page) => stripPageTraversalMetadata(page));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,69 +594,82 @@ export class PageRepo {
|
||||
* More efficient than getPageAndDescendants + filtering because:
|
||||
* 1. Single DB query (no separate restricted IDs query)
|
||||
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
|
||||
* 3. No in-memory filtering needed
|
||||
* 3. Filters the bounded traversal only after hierarchy validation
|
||||
*/
|
||||
async getPageAndDescendantsExcludingRestricted(
|
||||
parentPageId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
return (
|
||||
this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.where('p.deletedAt', 'is', null)
|
||||
// Only recurse into children of non-restricted pages
|
||||
.where('ph.isRestricted', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
// Filter out restricted pages from the result
|
||||
.where('isRestricted', '=', false)
|
||||
.execute()
|
||||
);
|
||||
const pages = await this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
sql<string[]>`ARRAY[pages.id]::uuid[]`.as('traversalPath'),
|
||||
sql<boolean>`false`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
|
||||
.where('pages.id', '=', parentPageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
sql<string[]>`ph.traversal_path || p.id`.as('traversalPath'),
|
||||
sql<boolean>`p.id = ANY(ph.traversal_path)`.as('isCycle'),
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.where('p.deletedAt', 'is', null)
|
||||
// Only recurse into children of non-restricted pages
|
||||
.where('ph.isRestricted', '=', false)
|
||||
.where('ph.isCycle', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'isRestricted',
|
||||
'isCycle',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.execute();
|
||||
|
||||
assertAcyclicPageTraversal(pages, parentPageId);
|
||||
return pages
|
||||
.filter((page) => !page.isRestricted)
|
||||
.map((page) => {
|
||||
const withoutCycleMetadata = stripPageTraversalMetadata(page);
|
||||
const { isRestricted: _isRestricted, ...publicPage } =
|
||||
withoutCycleMetadata;
|
||||
return publicPage;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-26
@@ -74,7 +74,6 @@ export interface Audit {
|
||||
resourceId: string | null;
|
||||
resourceType: string;
|
||||
spaceId: string | null;
|
||||
userAgent: string | null;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@@ -362,30 +361,6 @@ export interface SpaceMembers {
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export interface SiemDestinations {
|
||||
config: Json;
|
||||
consecutiveFailures: Generated<number>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
cursorCreatedAt: Generated<Timestamp>;
|
||||
cursorId: Generated<string>;
|
||||
cursorSnapshot: string | null;
|
||||
enabled: Generated<boolean>;
|
||||
failingSince: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
lastDeliveredAt: Timestamp | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: Timestamp | null;
|
||||
name: string;
|
||||
nextAttemptAt: Timestamp | null;
|
||||
secrets: string;
|
||||
status: Generated<string>;
|
||||
type: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
version: Generated<number>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Spaces {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -749,7 +724,6 @@ export interface DB {
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
siemDestinations: SiemDestinations;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
templates: Templates;
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
ScimTokens,
|
||||
SiemDestinations,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
@@ -268,8 +267,3 @@ export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
export type InsertableBaseView = Insertable<BaseViews>;
|
||||
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
|
||||
|
||||
// SIEM destinations
|
||||
export type SiemDestination = Selectable<SiemDestinations>;
|
||||
export type InsertableSiemDestination = Insertable<SiemDestinations>;
|
||||
export type UpdatableSiemDestination = Updateable<Omit<SiemDestinations, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: c8ca1467df...844f2003cd
@@ -385,8 +385,4 @@ export class EnvironmentService {
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
getAllowedPrivateNetworks(): string {
|
||||
return this.configService.get<string>('ALLOWED_PRIVATE_NETWORKS', 'none');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Agent } from 'undici';
|
||||
import { OutboundAgentFactory } from './outbound-agent.factory';
|
||||
import { OutboundUrlError } from './outbound-url.guard';
|
||||
|
||||
describe('OutboundAgentFactory', () => {
|
||||
it('validates the URL through the guard and returns a releasable undici Agent', async () => {
|
||||
const validate = jest.fn().mockResolvedValue({ hostname: 'siem.example.com', address: '203.0.113.5', family: 4 });
|
||||
const factory = new OutboundAgentFactory({ validate } as any);
|
||||
|
||||
const lease = await factory.lease('https://siem.example.com/ingest', { caCert: undefined, rejectUnauthorized: true });
|
||||
|
||||
expect(validate).toHaveBeenCalledWith('https://siem.example.com/ingest');
|
||||
expect(lease.dispatcher).toBeInstanceOf(Agent);
|
||||
await expect(lease.release()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates guard rejections', async () => {
|
||||
const validate = jest.fn().mockRejectedValue(new OutboundUrlError('Destination URL must use https'));
|
||||
const factory = new OutboundAgentFactory({ validate } as any);
|
||||
|
||||
await expect(factory.lease('http://siem.example.com')).rejects.toThrow(OutboundUrlError);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Agent, Dispatcher } from 'undici';
|
||||
import { OutboundUrlGuard } from './outbound-url.guard';
|
||||
|
||||
export const OUTBOUND_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type OutboundTlsOptions = {
|
||||
caCert?: string; // PEM encoded
|
||||
rejectUnauthorized?: boolean; // Defaults to true; self-hosted only when false.
|
||||
};
|
||||
|
||||
export type AgentLease = {
|
||||
dispatcher: Dispatcher;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type IOutboundAgentFactory = {
|
||||
lease(url: string, tls?: OutboundTlsOptions): Promise<AgentLease>;
|
||||
};
|
||||
|
||||
/** Creates a per-request agent pinned to the address validated by the SSRF guard. */
|
||||
@Injectable()
|
||||
export class OutboundAgentFactory implements IOutboundAgentFactory {
|
||||
constructor(private readonly urlGuard: OutboundUrlGuard) {}
|
||||
|
||||
async lease(url: string, tls?: OutboundTlsOptions): Promise<AgentLease> {
|
||||
const pinned = await this.urlGuard.validate(url);
|
||||
|
||||
const lookup = (_hostname: string, options: any, callback: any) => {
|
||||
if (options?.all) {
|
||||
callback(null, [{ address: pinned.address, family: pinned.family }]);
|
||||
} else {
|
||||
callback(null, pinned.address, pinned.family);
|
||||
}
|
||||
};
|
||||
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
ca: tls?.caCert || undefined,
|
||||
rejectUnauthorized: tls?.rejectUnauthorized ?? true,
|
||||
lookup: lookup as any,
|
||||
timeout: OUTBOUND_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
headersTimeout: OUTBOUND_REQUEST_TIMEOUT_MS,
|
||||
bodyTimeout: OUTBOUND_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher: agent,
|
||||
release: async () => {
|
||||
await agent.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
parseOutboundNetworkPolicy,
|
||||
policyNamesAddress,
|
||||
} from './outbound-network-policy';
|
||||
|
||||
describe('parseOutboundNetworkPolicy', () => {
|
||||
it('parses a bare mode', () => {
|
||||
expect(parseOutboundNetworkPolicy('all')).toMatchObject({
|
||||
mode: 'all',
|
||||
entries: [],
|
||||
invalid: false,
|
||||
});
|
||||
expect(parseOutboundNetworkPolicy('none')).toMatchObject({
|
||||
mode: 'none',
|
||||
entries: [],
|
||||
invalid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty value as none with no entries', () => {
|
||||
for (const raw of ['', ' ', ',,']) {
|
||||
expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
|
||||
mode: 'none',
|
||||
entries: [],
|
||||
invalid: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores case and surrounding whitespace on the mode', () => {
|
||||
expect(parseOutboundNetworkPolicy(' ALL ')).toMatchObject({
|
||||
mode: 'all',
|
||||
invalid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a mode followed by entries', () => {
|
||||
const policy = parseOutboundNetworkPolicy('all,127.0.0.0/8,::1/128');
|
||||
|
||||
expect(policy.mode).toBe('all');
|
||||
expect(policy.entries).toHaveLength(2);
|
||||
expect(policyNamesAddress(policy, '127.0.0.1', 80)).toBe(true);
|
||||
expect(policyNamesAddress(policy, '127.0.0.1', 8088)).toBe(true);
|
||||
expect(policyNamesAddress(policy, '::1', 443)).toBe(true);
|
||||
expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(false);
|
||||
});
|
||||
|
||||
it('parses an entry with a port and matches only that port', () => {
|
||||
const policy = parseOutboundNetworkPolicy('none,192.168.1.20/32:8088');
|
||||
|
||||
expect(policy.mode).toBe('none');
|
||||
expect(policyNamesAddress(policy, '192.168.1.20', 8088)).toBe(true);
|
||||
expect(policyNamesAddress(policy, '192.168.1.20', 443)).toBe(false);
|
||||
expect(policyNamesAddress(policy, '192.168.1.21', 8088)).toBe(false);
|
||||
});
|
||||
|
||||
it('parses a bracketed IPv6 entry with a port', () => {
|
||||
const policy = parseOutboundNetworkPolicy('[::1/128]:8088');
|
||||
|
||||
expect(policy.mode).toBe('none');
|
||||
expect(policyNamesAddress(policy, '::1', 8088)).toBe(true);
|
||||
expect(policyNamesAddress(policy, '::1', 80)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats entries without a mode as none plus those entries', () => {
|
||||
const policy = parseOutboundNetworkPolicy('10.0.0.0/8');
|
||||
|
||||
expect(policy.mode).toBe('none');
|
||||
expect(policy.invalid).toBe(false);
|
||||
expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(true);
|
||||
expect(policyNamesAddress(policy, '192.168.1.1', 443)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'not-a-cidr',
|
||||
'all,not-a-cidr',
|
||||
'all,10.0.0.0/8,nonsense',
|
||||
'10.0.0.0/33',
|
||||
'10.0.0.0',
|
||||
'::1/129',
|
||||
'::1/128:8088',
|
||||
'10.0.0.0/8:0',
|
||||
'10.0.0.0/8:70000',
|
||||
'[::1/128]:notaport',
|
||||
'0.0.0.0/0',
|
||||
'::/0',
|
||||
'all,0.0.0.0/0',
|
||||
'[::/0]:8088',
|
||||
'192.168.1.20/24',
|
||||
'10.1.0.0/8',
|
||||
'172.16.0.1/12',
|
||||
'fc00::1/7',
|
||||
'[::1/127]',
|
||||
'2001:db8::1/32:8088',
|
||||
])('fails closed on %s', (raw) => {
|
||||
expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
|
||||
mode: 'none',
|
||||
entries: [],
|
||||
invalid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'100.64.0.0/10',
|
||||
'192.168.1.20/32',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
'::1/128',
|
||||
])('accepts %s, whose address sits on its prefix boundary', (raw) => {
|
||||
expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
|
||||
entries: [expect.anything()],
|
||||
invalid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bracketed IPv6 entry without a port as the unbracketed form', () => {
|
||||
const bracketed = parseOutboundNetworkPolicy('[::1/128]');
|
||||
const bare = parseOutboundNetworkPolicy('::1/128');
|
||||
|
||||
expect(bracketed).toMatchObject({ mode: 'none', invalid: false });
|
||||
for (const port of [80, 443, 8088]) {
|
||||
expect(policyNamesAddress(bracketed, '::1', port)).toBe(
|
||||
policyNamesAddress(bare, '::1', port),
|
||||
);
|
||||
expect(policyNamesAddress(bracketed, '::1', port)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('never names an address when the value is unparseable or the address is not an IP', () => {
|
||||
const policy = parseOutboundNetworkPolicy('all,10.0.0.0/8');
|
||||
|
||||
expect(policyNamesAddress(policy, 'siem.internal', 443)).toBe(false);
|
||||
expect(policyNamesAddress(parseOutboundNetworkPolicy('garbage'), '10.1.2.3', 443)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches an IPv4-mapped IPv6 address against an IPv4 entry', () => {
|
||||
const policy = parseOutboundNetworkPolicy('127.0.0.0/8');
|
||||
|
||||
expect(policyNamesAddress(policy, '::ffff:127.0.0.1', 80)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { BlockList, isIPv4, isIPv6 } from 'node:net';
|
||||
|
||||
export type OutboundPolicyMode = 'all' | 'none';
|
||||
|
||||
export type OutboundPolicyEntry = { list: BlockList; port?: number };
|
||||
|
||||
/** An invalid policy denies all private destinations. */
|
||||
export type OutboundNetworkPolicy = {
|
||||
mode: OutboundPolicyMode;
|
||||
entries: OutboundPolicyEntry[];
|
||||
invalid: boolean;
|
||||
};
|
||||
|
||||
function toBytes(address: string, family: 'ipv4' | 'ipv6'): number[] {
|
||||
if (family === 'ipv4') return address.split('.').map(Number);
|
||||
|
||||
const bytesOf = (part: string): number[] =>
|
||||
part
|
||||
? part.split(':').flatMap((group) => {
|
||||
if (group.includes('.')) return group.split('.').map(Number);
|
||||
const value = parseInt(group, 16);
|
||||
return [value >> 8, value & 0xff];
|
||||
})
|
||||
: [];
|
||||
|
||||
const [head, tail] = address.split('::');
|
||||
const headBytes = bytesOf(head);
|
||||
const tailBytes = address.includes('::') ? bytesOf(tail) : [];
|
||||
const zeros = new Array(16 - headBytes.length - tailBytes.length).fill(0);
|
||||
return [...headBytes, ...zeros, ...tailBytes];
|
||||
}
|
||||
|
||||
function hasHostBits(bytes: number[], prefix: number): boolean {
|
||||
return bytes.some((byte, index) => {
|
||||
const bitsBefore = index * 8;
|
||||
if (bitsBefore >= prefix) return byte !== 0;
|
||||
return (byte & (0xff >> Math.min(8, prefix - bitsBefore))) !== 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Prefix zero is reserved for the explicit `all` mode. */
|
||||
function parseCidr(
|
||||
raw: string,
|
||||
): { address: string; prefix: number; family: 'ipv4' | 'ipv6' } | null {
|
||||
const [address, prefixRaw] = raw.split('/');
|
||||
if (!prefixRaw) return null;
|
||||
const prefix = Number(prefixRaw);
|
||||
if (!Number.isInteger(prefix) || prefix < 1) return null;
|
||||
const family = isIPv4(address) ? 'ipv4' : isIPv6(address) ? 'ipv6' : null;
|
||||
if (!family) return null;
|
||||
if (prefix > (family === 'ipv4' ? 32 : 128)) return null;
|
||||
if (hasHostBits(toBytes(address, family), prefix)) return null;
|
||||
return { address, prefix, family };
|
||||
}
|
||||
|
||||
/** Parses optional ports without treating IPv6 colons as separators. */
|
||||
function splitPort(token: string): { cidr: string; port?: number } {
|
||||
const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(token);
|
||||
if (bracketed) {
|
||||
const [, cidr, port] = bracketed;
|
||||
return port === undefined ? { cidr } : { cidr, port: Number(port) };
|
||||
}
|
||||
const withPort = /^([^:]+):(\d+)$/.exec(token);
|
||||
if (withPort) return { cidr: withPort[1], port: Number(withPort[2]) };
|
||||
return { cidr: token };
|
||||
}
|
||||
|
||||
function parseEntry(token: string): OutboundPolicyEntry | null {
|
||||
const { cidr: raw, port } = splitPort(token);
|
||||
if (port !== undefined && (port < 1 || port > 65535)) return null;
|
||||
const cidr = parseCidr(raw);
|
||||
if (!cidr) return null;
|
||||
const list = new BlockList();
|
||||
list.addSubnet(cidr.address, cidr.prefix, cidr.family);
|
||||
return { list, port };
|
||||
}
|
||||
|
||||
/** Parses `[all|none,]CIDR[:port],...` and fails closed on invalid input. */
|
||||
export function parseOutboundNetworkPolicy(raw: string): OutboundNetworkPolicy {
|
||||
const tokens = (raw ?? '')
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return { mode: 'none', entries: [], invalid: false };
|
||||
|
||||
const first = tokens[0].toLowerCase();
|
||||
const hasMode = first === 'all' || first === 'none';
|
||||
const mode: OutboundPolicyMode = hasMode ? first : 'none';
|
||||
|
||||
const entries: OutboundPolicyEntry[] = [];
|
||||
for (const token of hasMode ? tokens.slice(1) : tokens) {
|
||||
const entry = parseEntry(token);
|
||||
if (!entry) return { mode: 'none', entries: [], invalid: true };
|
||||
entries.push(entry);
|
||||
}
|
||||
return { mode, entries, invalid: false };
|
||||
}
|
||||
|
||||
export function policyNamesAddress(
|
||||
policy: OutboundNetworkPolicy,
|
||||
ip: string,
|
||||
port: number,
|
||||
): boolean {
|
||||
const family = isIPv4(ip) ? 'ipv4' : isIPv6(ip) ? 'ipv6' : null;
|
||||
if (!family) return false;
|
||||
return policy.entries.some(
|
||||
(entry) =>
|
||||
(entry.port === undefined || entry.port === port) && entry.list.check(ip, family),
|
||||
);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
isAlwaysBlockedAddress,
|
||||
isHardBlockedAddress,
|
||||
isPrivateAddress,
|
||||
isPrivateNetworkAddress,
|
||||
OutboundUrlError,
|
||||
OutboundUrlGuard,
|
||||
} from './outbound-url.guard';
|
||||
|
||||
function guard(
|
||||
isCloud: boolean,
|
||||
addresses: Array<{ address: string; family: number }>,
|
||||
privateNetworks: string = 'none',
|
||||
) {
|
||||
return new OutboundUrlGuard(
|
||||
{
|
||||
isCloud: () => isCloud,
|
||||
getAllowedPrivateNetworks: () => privateNetworks,
|
||||
} as any,
|
||||
async () => addresses,
|
||||
);
|
||||
}
|
||||
|
||||
function family(ip: string): number {
|
||||
return ip.includes(':') ? 6 : 4;
|
||||
}
|
||||
|
||||
describe('isPrivateAddress', () => {
|
||||
it.each([
|
||||
'127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1',
|
||||
'169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1',
|
||||
'::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:10.0.0.1',
|
||||
'0:0:0:0:0:0:0:1', '::ffff:a00:1', '::ffff:7f00:1', '0000:0000:0000:0000:0000:0000:0000:0000',
|
||||
'192.0.0.1', '192.0.2.1', '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5',
|
||||
'::a00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', '100::1', '2001::1', '2001:0:a00:1::1', '2001:db8::1', '2002:a00:1::1', 'fec0::1',
|
||||
])('flags %s as private or reserved', (ip) => {
|
||||
expect(isPrivateAddress(ip)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '::ffff:8.8.8.8', '::ffff:5db8:d822', '::ffff:8.8.8.8', '2001:4860:4860::8888', '100.128.0.1', '198.17.255.255'])(
|
||||
'allows public %s',
|
||||
(ip) => {
|
||||
expect(isPrivateAddress(ip)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('isAlwaysBlockedAddress / isPrivateNetworkAddress', () => {
|
||||
it.each([
|
||||
'0.0.0.0', '127.0.0.1', '169.254.169.254', '192.0.0.1', '192.0.2.1',
|
||||
'192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', '224.0.0.1',
|
||||
'::1', '::', '::ffff:127.0.0.1', '::ffff:0:7f00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1',
|
||||
'100::1', '2001::1', '2001:db8::1', '2002:a00:1::1', 'fe80::1', 'fec0::1',
|
||||
'ff02::1',
|
||||
])('flags %s as always-blocked but not a private network', (ip) => {
|
||||
expect(isAlwaysBlockedAddress(ip)).toBe(true);
|
||||
expect(isPrivateNetworkAddress(ip)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '100.64.0.1',
|
||||
'fc00::1', 'fd12::1',
|
||||
])('flags %s as a private network but not always-blocked', (ip) => {
|
||||
expect(isPrivateNetworkAddress(ip)).toBe(true);
|
||||
expect(isAlwaysBlockedAddress(ip)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '100.128.0.1'])(
|
||||
'allows public %s in both',
|
||||
(ip) => {
|
||||
expect(isAlwaysBlockedAddress(ip)).toBe(false);
|
||||
expect(isPrivateNetworkAddress(ip)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('the two lists together are exactly isPrivateAddress', () => {
|
||||
for (const ip of ['10.0.0.5', '127.0.0.1', '8.8.8.8', 'fe80::1', 'fc00::1']) {
|
||||
expect(isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip)).toBe(
|
||||
isPrivateAddress(ip),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OutboundUrlGuard.validate', () => {
|
||||
const publicV4 = { address: '93.184.216.34', family: 4 };
|
||||
|
||||
it('rejects http on cloud', async () => {
|
||||
await expect(guard(true, [publicV4]).validate('http://siem.example.com/x'))
|
||||
.rejects.toThrow(OutboundUrlError);
|
||||
});
|
||||
|
||||
it('rejects hosts that resolve to a private range on cloud', async () => {
|
||||
await expect(
|
||||
guard(true, [publicV4, { address: '10.0.0.5', family: 4 }]).validate('https://siem.example.com'),
|
||||
).rejects.toThrow(/private or reserved/);
|
||||
});
|
||||
|
||||
it('rejects the cloud metadata address literal', async () => {
|
||||
await expect(guard(true, []).validate('https://169.254.169.254/latest'))
|
||||
.rejects.toThrow(/private or reserved/);
|
||||
});
|
||||
|
||||
it('allows LAN hosts and http on self-hosted when private networks are allowed', async () => {
|
||||
const pinned = await guard(false, [{ address: '10.0.5.20', family: 4 }], 'all')
|
||||
.validate('http://splunk.internal:8088/services/collector/event');
|
||||
expect(pinned).toEqual({ hostname: 'splunk.internal', address: '10.0.5.20', family: 4 });
|
||||
});
|
||||
|
||||
it('pins the first resolved address and keeps the hostname for SNI', async () => {
|
||||
const pinned = await guard(true, [{ address: '2606:4700::1111', family: 6 }, publicV4])
|
||||
.validate('https://siem.example.com');
|
||||
expect(pinned).toEqual({ hostname: 'siem.example.com', address: '2606:4700::1111', family: 6 });
|
||||
});
|
||||
|
||||
it('rejects credentials in the URL and unresolvable hosts', async () => {
|
||||
await expect(guard(false, [publicV4]).validate('https://user:pw@siem.example.com'))
|
||||
.rejects.toThrow(/credentials/);
|
||||
await expect(guard(false, []).validate('https://nope.example.com'))
|
||||
.rejects.toThrow(/Could not resolve/);
|
||||
});
|
||||
|
||||
it('marks resolution failures retryable and configuration failures not', async () => {
|
||||
const throwing = new OutboundUrlGuard(
|
||||
{ isCloud: () => false } as any,
|
||||
async () => {
|
||||
throw new Error('EAI_AGAIN');
|
||||
},
|
||||
);
|
||||
|
||||
const dnsError = await throwing
|
||||
.validate('https://siem.example.com')
|
||||
.catch((e) => e);
|
||||
expect(dnsError).toBeInstanceOf(OutboundUrlError);
|
||||
expect(dnsError.retryable).toBe(true);
|
||||
|
||||
const emptyError = await guard(false, [])
|
||||
.validate('https://nope.example.com')
|
||||
.catch((e) => e);
|
||||
expect(emptyError.retryable).toBe(true);
|
||||
|
||||
for (const url of [
|
||||
'not-a-url',
|
||||
'ftp://siem.example.com',
|
||||
'https://user:pw@siem.example.com',
|
||||
]) {
|
||||
const err = await guard(false, [publicV4])
|
||||
.validate(url)
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(OutboundUrlError);
|
||||
expect(err.retryable).toBe(false);
|
||||
}
|
||||
|
||||
const privateError = await guard(true, [{ address: '10.0.0.5', family: 4 }])
|
||||
.validate('https://siem.example.com')
|
||||
.catch((e) => e);
|
||||
expect(privateError.retryable).toBe(false);
|
||||
});
|
||||
|
||||
const hardBlocked = ['169.254.169.254', '0.0.0.0', 'fe80::1', 'ff02::1'];
|
||||
const loopbackOrReserved = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '192.0.2.1'];
|
||||
|
||||
it.each(hardBlocked)(
|
||||
'self-hosted refuses %s under every ALLOWED_PRIVATE_NETWORKS value',
|
||||
async (ip) => {
|
||||
for (const value of ['all', 'none', '169.254.0.0/16', 'all,169.254.0.0/16', 'all,fe80::/10']) {
|
||||
await expect(
|
||||
guard(false, [{ address: ip, family: family(ip) }], value).validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(/link-local, metadata or reserved address .* which is never allowed/);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(loopbackOrReserved)(
|
||||
'self-hosted refuses %s unless an entry names it',
|
||||
async (ip) => {
|
||||
for (const value of ['all', 'none']) {
|
||||
await expect(
|
||||
guard(false, [{ address: ip, family: family(ip) }], value).validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/resolves to a loopback or reserved address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['10.1.2.3', '192.168.1.10'])(
|
||||
'self-hosted refuses private network %s by default',
|
||||
async (ip) => {
|
||||
await expect(
|
||||
guard(false, [{ address: ip, family: 4 }]).validate('http://siem.internal'),
|
||||
).rejects.toThrow(
|
||||
/resolves to a private address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['10.1.2.3', '192.168.1.10', 'fc00::1', '100.64.0.1'])(
|
||||
'all accepts private network %s',
|
||||
async (ip) => {
|
||||
const pinned = await guard(
|
||||
false,
|
||||
[{ address: ip, family: family(ip) }],
|
||||
'all',
|
||||
).validate('http://siem.internal');
|
||||
expect(pinned.address).toBe(ip);
|
||||
},
|
||||
);
|
||||
|
||||
it('all still refuses loopback, and a loopback entry opts it back in', async () => {
|
||||
await expect(
|
||||
guard(false, [{ address: '127.0.0.1', family: 4 }], 'all').validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(/loopback or reserved/);
|
||||
|
||||
const allowed = await guard(
|
||||
false,
|
||||
[{ address: '127.0.0.1', family: 4 }],
|
||||
'all,127.0.0.0/8',
|
||||
).validate('http://siem.internal');
|
||||
expect(allowed.address).toBe('127.0.0.1');
|
||||
|
||||
const lan = await guard(
|
||||
false,
|
||||
[{ address: '10.1.2.3', family: 4 }],
|
||||
'all,127.0.0.0/8',
|
||||
).validate('http://siem.internal');
|
||||
expect(lan.address).toBe('10.1.2.3');
|
||||
|
||||
await expect(
|
||||
guard(false, [{ address: '::1', family: 6 }], 'all,127.0.0.0/8').validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(/loopback or reserved/);
|
||||
});
|
||||
|
||||
it('an entry with a port matches only that port', async () => {
|
||||
const policy = 'none,192.168.1.20/32:8088';
|
||||
|
||||
const allowed = await guard(
|
||||
false,
|
||||
[{ address: '192.168.1.20', family: 4 }],
|
||||
policy,
|
||||
).validate('https://192.168.1.20:8088/services/collector/event');
|
||||
expect(allowed.address).toBe('192.168.1.20');
|
||||
|
||||
await expect(
|
||||
guard(false, [{ address: '192.168.1.20', family: 4 }], policy).validate(
|
||||
'https://192.168.1.20',
|
||||
),
|
||||
).rejects.toThrow(/private address/);
|
||||
|
||||
await expect(
|
||||
guard(false, [{ address: '192.168.1.21', family: 4 }], policy).validate(
|
||||
'https://192.168.1.21:8088',
|
||||
),
|
||||
).rejects.toThrow(/private address/);
|
||||
});
|
||||
|
||||
it('a bracketed IPv6 entry with a port accepts only that port', async () => {
|
||||
const policy = '[::1/128]:8088';
|
||||
|
||||
const allowed = await guard(
|
||||
false,
|
||||
[{ address: '::1', family: 6 }],
|
||||
policy,
|
||||
).validate('http://[::1]:8088/ingest');
|
||||
expect(allowed).toEqual({ hostname: '::1', address: '::1', family: 6 });
|
||||
|
||||
await expect(
|
||||
guard(false, [{ address: '::1', family: 6 }], policy).validate('http://[::1]/ingest'),
|
||||
).rejects.toThrow(/loopback or reserved/);
|
||||
});
|
||||
|
||||
it('an entry without a port matches every port', async () => {
|
||||
for (const url of ['http://127.0.0.1:8088', 'https://127.0.0.1', 'http://127.0.0.1']) {
|
||||
const allowed = await guard(
|
||||
false,
|
||||
[{ address: '127.0.0.1', family: 4 }],
|
||||
'127.0.0.0/8',
|
||||
).validate(url);
|
||||
expect(allowed.address).toBe('127.0.0.1');
|
||||
}
|
||||
});
|
||||
|
||||
it('entries without a mode none every private network not named', async () => {
|
||||
const allowed = await guard(
|
||||
false,
|
||||
[{ address: '192.168.1.10', family: 4 }],
|
||||
'192.168.1.0/24',
|
||||
).validate('http://siem.internal');
|
||||
expect(allowed.address).toBe('192.168.1.10');
|
||||
|
||||
await expect(
|
||||
guard(false, [{ address: '10.1.2.3', family: 4 }], '192.168.1.0/24').validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(/private address/);
|
||||
});
|
||||
|
||||
it('an unparseable value denies everything private or reserved and logs once per process', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const g = guard(false, [{ address: '10.1.2.3', family: 4 }], 'all,10.0.0.0/8, not-a-cidr');
|
||||
|
||||
await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/);
|
||||
await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy.mock.calls[0][0]).toMatch(/ALLOWED_PRIVATE_NETWORKS/);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cloud ignores ALLOWED_PRIVATE_NETWORKS and always refuses private and reserved ranges', async () => {
|
||||
for (const ip of [...hardBlocked, ...loopbackOrReserved, '10.1.2.3', '192.168.1.10']) {
|
||||
for (const value of ['all', 'none', '127.0.0.0/8', 'all,10.0.0.0/8']) {
|
||||
await expect(
|
||||
guard(true, [{ address: ip, family: family(ip) }], value).validate(
|
||||
'https://siem.example.com',
|
||||
),
|
||||
).rejects.toThrow(/private or reserved/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a resolved address that is not an IP address', async () => {
|
||||
await expect(
|
||||
guard(false, [{ address: 'not-an-ip', family: 4 }], 'all').validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(/is not an IP address/);
|
||||
});
|
||||
|
||||
it('refuses the whole host when any one of its addresses is refused', async () => {
|
||||
await expect(
|
||||
guard(
|
||||
false,
|
||||
[publicV4, { address: '10.1.2.3', family: 4 }],
|
||||
'none',
|
||||
).validate('http://siem.internal'),
|
||||
).rejects.toThrow(/private address/);
|
||||
|
||||
await expect(
|
||||
guard(
|
||||
false,
|
||||
[publicV4, { address: '127.0.0.1', family: 4 }],
|
||||
'all',
|
||||
).validate('http://siem.internal'),
|
||||
).rejects.toThrow(/loopback or reserved/);
|
||||
|
||||
await expect(
|
||||
guard(
|
||||
false,
|
||||
[publicV4, { address: '169.254.169.254', family: 4 }],
|
||||
'all',
|
||||
).validate('http://siem.internal'),
|
||||
).rejects.toThrow(/never allowed/);
|
||||
});
|
||||
|
||||
it('refuses an IPv4-translated loopback address even when private networks are allowed', async () => {
|
||||
await expect(
|
||||
guard(false, [{ address: '::ffff:0:7f00:1', family: 6 }], 'all').validate(
|
||||
'http://siem.internal',
|
||||
),
|
||||
).rejects.toThrow(/loopback or reserved/);
|
||||
});
|
||||
|
||||
it('a public address is allowed in every mode', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
for (const value of ['all', 'none', '', '192.168.1.0/24', 'garbage']) {
|
||||
await expect(
|
||||
guard(false, [publicV4], value).validate('http://siem.example.com'),
|
||||
).resolves.toMatchObject({ address: publicV4.address });
|
||||
}
|
||||
await expect(
|
||||
guard(true, [publicV4], 'all').validate('https://siem.example.com'),
|
||||
).resolves.toMatchObject({ address: publicV4.address });
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHardBlockedAddress', () => {
|
||||
it.each([
|
||||
'0.0.0.0', '169.254.169.254', '224.0.0.1', '255.255.255.255',
|
||||
'::', 'fe80::1', 'ff02::1',
|
||||
])('flags %s as hard-blocked', (ip) => {
|
||||
expect(isHardBlockedAddress(ip)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['127.0.0.1', '::1', '192.0.2.1', 'fec0::1', '8.8.8.8'])(
|
||||
'does not flag %s as hard-blocked (it may still be always-blocked)',
|
||||
(ip) => {
|
||||
expect(isHardBlockedAddress(ip)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('is a subset of isAlwaysBlockedAddress', () => {
|
||||
for (const ip of ['0.0.0.0', '169.254.169.254', 'fe80::1', 'ff02::1']) {
|
||||
expect(isAlwaysBlockedAddress(ip)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { promises as dns } from 'node:dns';
|
||||
import { BlockList, isIPv4, isIPv6 } from 'node:net';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import {
|
||||
OutboundNetworkPolicy,
|
||||
parseOutboundNetworkPolicy,
|
||||
policyNamesAddress,
|
||||
} from './outbound-network-policy';
|
||||
|
||||
export const OUTBOUND_LOOKUP = 'OUTBOUND_LOOKUP';
|
||||
|
||||
export type ResolvedAddress = { address: string; family: number };
|
||||
export type LookupFn = (hostname: string) => Promise<ResolvedAddress[]>;
|
||||
export type PinnedAddress = { hostname: string; address: string; family: 4 | 6 };
|
||||
|
||||
/** A rejected URL. Only transient resolution failures are retryable. */
|
||||
export class OutboundUrlError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly retryable: boolean = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'OutboundUrlError';
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultLookup: LookupFn = async (hostname) => {
|
||||
const results = await dns.lookup(hostname, { all: true });
|
||||
return results.map((r) => ({ address: r.address, family: r.family }));
|
||||
};
|
||||
|
||||
// Reserved ranges blocked unless explicitly allowed on self-hosted deployments.
|
||||
const ALWAYS_BLOCKED = new BlockList();
|
||||
ALWAYS_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); // "this" network / unspecified
|
||||
ALWAYS_BLOCKED.addSubnet('127.0.0.0', 8, 'ipv4');
|
||||
ALWAYS_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local / cloud metadata
|
||||
ALWAYS_BLOCKED.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments
|
||||
ALWAYS_BLOCKED.addSubnet('192.0.2.0', 24, 'ipv4'); // TEST-NET-1
|
||||
ALWAYS_BLOCKED.addSubnet('192.88.99.0', 24, 'ipv4'); // deprecated 6to4 relay anycast
|
||||
ALWAYS_BLOCKED.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking
|
||||
ALWAYS_BLOCKED.addSubnet('198.51.100.0', 24, 'ipv4'); // TEST-NET-2
|
||||
ALWAYS_BLOCKED.addSubnet('203.0.113.0', 24, 'ipv4'); // TEST-NET-3
|
||||
ALWAYS_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); // multicast + reserved
|
||||
ALWAYS_BLOCKED.addSubnet('::', 96, 'ipv6'); // deprecated IPv4-compatible
|
||||
ALWAYS_BLOCKED.addSubnet('::ffff:0:0:0', 96, 'ipv6'); // IPv4-translated (SIIT): ::ffff:0:7f00:1 is 127.0.0.1
|
||||
ALWAYS_BLOCKED.addSubnet('::', 128, 'ipv6'); // unspecified
|
||||
ALWAYS_BLOCKED.addSubnet('::1', 128, 'ipv6'); // loopback
|
||||
ALWAYS_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix
|
||||
ALWAYS_BLOCKED.addSubnet('64:ff9b:1::', 48, 'ipv6'); // NAT64 local-use
|
||||
ALWAYS_BLOCKED.addSubnet('100::', 64, 'ipv6'); // discard-only
|
||||
ALWAYS_BLOCKED.addSubnet('2001::', 32, 'ipv6'); // Teredo
|
||||
ALWAYS_BLOCKED.addSubnet('2001:db8::', 32, 'ipv6'); // documentation
|
||||
ALWAYS_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4
|
||||
ALWAYS_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local
|
||||
ALWAYS_BLOCKED.addSubnet('fec0::', 10, 'ipv6'); // deprecated site-local
|
||||
ALWAYS_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast
|
||||
|
||||
// Private ranges that self-hosted deployments can allow.
|
||||
const PRIVATE_NETWORKS = new BlockList();
|
||||
PRIVATE_NETWORKS.addSubnet('10.0.0.0', 8, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT
|
||||
PRIVATE_NETWORKS.addSubnet('172.16.0.0', 12, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('192.168.0.0', 16, 'ipv4');
|
||||
PRIVATE_NETWORKS.addSubnet('fc00::', 7, 'ipv6'); // unique-local
|
||||
|
||||
// These ranges cannot be allowed by policy.
|
||||
const HARD_BLOCKED = new BlockList();
|
||||
HARD_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4');
|
||||
HARD_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4');
|
||||
HARD_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4');
|
||||
HARD_BLOCKED.addSubnet('::', 128, 'ipv6');
|
||||
HARD_BLOCKED.addSubnet('fe80::', 10, 'ipv6');
|
||||
HARD_BLOCKED.addSubnet('ff00::', 8, 'ipv6');
|
||||
|
||||
/** Returns true for reserved or transition ranges. Invalid input is blocked. */
|
||||
export function isAlwaysBlockedAddress(ip: string): boolean {
|
||||
if (isIPv4(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv4');
|
||||
if (isIPv6(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv6');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true for ranges that policy cannot allow. Invalid input is blocked. */
|
||||
export function isHardBlockedAddress(ip: string): boolean {
|
||||
if (isIPv4(ip)) return HARD_BLOCKED.check(ip, 'ipv4');
|
||||
if (isIPv6(ip)) return HARD_BLOCKED.check(ip, 'ipv6');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true for private network ranges. Invalid input is blocked. */
|
||||
export function isPrivateNetworkAddress(ip: string): boolean {
|
||||
if (isIPv4(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv4');
|
||||
if (isIPv6(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv6');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true for addresses blocked by cloud deployments. */
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
return isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip);
|
||||
}
|
||||
|
||||
type Refusal = {
|
||||
address: string;
|
||||
kind: 'not-an-ip' | 'hard-blocked' | 'private' | 'reserved';
|
||||
};
|
||||
|
||||
function findRefusal(
|
||||
resolved: ResolvedAddress[],
|
||||
port: number,
|
||||
policy: OutboundNetworkPolicy,
|
||||
): Refusal | undefined {
|
||||
for (const { address } of resolved) {
|
||||
// Reject invalid resolver output before policy checks.
|
||||
if (!isIPv4(address) && !isIPv6(address)) return { address, kind: 'not-an-ip' };
|
||||
if (isHardBlockedAddress(address)) return { address, kind: 'hard-blocked' };
|
||||
if (policyNamesAddress(policy, address, port)) continue;
|
||||
if (isPrivateNetworkAddress(address)) {
|
||||
if (policy.mode === 'all') continue;
|
||||
return { address, kind: 'private' };
|
||||
}
|
||||
if (isAlwaysBlockedAddress(address)) return { address, kind: 'reserved' };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeRefusal(hostname: string, { address, kind }: Refusal): string {
|
||||
if (kind === 'not-an-ip') {
|
||||
return `Destination host "${hostname}" resolved to "${address}", which is not an IP address`;
|
||||
}
|
||||
if (kind === 'hard-blocked') {
|
||||
return `Destination host "${hostname}" resolves to a link-local, metadata or reserved address (${address}), which is never allowed`;
|
||||
}
|
||||
const description =
|
||||
kind === 'private' ? 'a private address' : 'a loopback or reserved address';
|
||||
return `Destination host "${hostname}" resolves to ${description} (${address}). Set ALLOWED_PRIVATE_NETWORKS on the server to allow it`;
|
||||
}
|
||||
|
||||
function effectivePort(url: URL): number {
|
||||
if (url.port) return Number(url.port);
|
||||
return url.protocol === 'https:' ? 443 : 80;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OutboundUrlGuard {
|
||||
private readonly logger = new Logger(OutboundUrlGuard.name);
|
||||
private readonly lookup: LookupFn;
|
||||
private cachedPolicy?: { raw: string; policy: OutboundNetworkPolicy };
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Optional() @Inject(OUTBOUND_LOOKUP) lookup?: LookupFn,
|
||||
) {
|
||||
this.lookup = lookup ?? defaultLookup;
|
||||
}
|
||||
|
||||
/** Caches the parsed policy and logs each invalid value once. */
|
||||
private resolvePolicy(): OutboundNetworkPolicy {
|
||||
const raw = this.environmentService.getAllowedPrivateNetworks();
|
||||
if (this.cachedPolicy?.raw !== raw) {
|
||||
const policy = parseOutboundNetworkPolicy(raw);
|
||||
if (policy.invalid) {
|
||||
this.logger.error(
|
||||
`Invalid ALLOWED_PRIVATE_NETWORKS value "${raw}"; refusing every private and reserved destination`,
|
||||
);
|
||||
}
|
||||
this.cachedPolicy = { raw, policy };
|
||||
}
|
||||
return this.cachedPolicy.policy;
|
||||
}
|
||||
|
||||
/** Validates the URL and returns the address used to pin the connection. */
|
||||
async validate(rawUrl: string): Promise<PinnedAddress> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new OutboundUrlError('Destination URL is not a valid URL');
|
||||
}
|
||||
|
||||
const isCloud = this.environmentService.isCloud();
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new OutboundUrlError('Destination URL must use http or https');
|
||||
}
|
||||
if (isCloud && url.protocol !== 'https:') {
|
||||
throw new OutboundUrlError('Destination URL must use https');
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new OutboundUrlError('Destination URL must not contain credentials');
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
||||
let resolved: ResolvedAddress[];
|
||||
if (isIPv4(hostname) || isIPv6(hostname)) {
|
||||
resolved = [{ address: hostname, family: isIPv4(hostname) ? 4 : 6 }];
|
||||
} else {
|
||||
try {
|
||||
resolved = await this.lookup(hostname);
|
||||
} catch {
|
||||
throw new OutboundUrlError(
|
||||
`Could not resolve destination host "${hostname}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resolved.length === 0) {
|
||||
throw new OutboundUrlError(
|
||||
`Could not resolve destination host "${hostname}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCloud) {
|
||||
const blocked = resolved.find((r) => isPrivateAddress(r.address));
|
||||
if (blocked) {
|
||||
throw new OutboundUrlError(
|
||||
`Destination host "${hostname}" resolves to a private or reserved address (${blocked.address}), which is not allowed`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const refusal = findRefusal(
|
||||
resolved,
|
||||
effectivePort(url),
|
||||
this.resolvePolicy(),
|
||||
);
|
||||
if (refusal) throw new OutboundUrlError(describeRefusal(hostname, refusal));
|
||||
}
|
||||
|
||||
const pick = resolved[0];
|
||||
return { hostname, address: pick.address, family: pick.family === 6 ? 6 : 4 };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { OutboundAgentFactory } from './outbound-agent.factory';
|
||||
import { OutboundUrlGuard } from './outbound-url.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [OutboundUrlGuard, OutboundAgentFactory],
|
||||
exports: [OutboundUrlGuard, OutboundAgentFactory],
|
||||
})
|
||||
export class OutboundModule {}
|
||||
@@ -10,7 +10,6 @@ export enum QueueName {
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
AUDIT_QUEUE = '{audit-queue}',
|
||||
BASE_QUEUE = '{base-queue}',
|
||||
SIEM_QUEUE = '{siem-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -84,9 +83,6 @@ export enum QueueJob {
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
SIEM_SWEEP = 'siem-sweep',
|
||||
SIEM_DELIVER = 'siem-deliver',
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
|
||||
|
||||
@@ -94,14 +94,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.SIEM_QUEUE,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 1,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.BASE_QUEUE,
|
||||
defaultJobOptions: {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
OAUTH_REGISTER_THROTTLER,
|
||||
OAUTH_TOKEN_THROTTLER,
|
||||
OAUTH_AUTHORIZE_THROTTLER,
|
||||
SIEM_TEST_THROTTLER,
|
||||
} from './throttler-names';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@@ -28,7 +27,6 @@ import Redis from 'ioredis';
|
||||
{ name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 },
|
||||
{ name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
|
||||
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
|
||||
{ name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
|
||||
],
|
||||
errorMessage: 'Too many requests',
|
||||
storage: new ThrottlerStorageRedisService(
|
||||
|
||||
@@ -3,7 +3,6 @@ export const AI_CHAT_THROTTLER = 'ai-chat';
|
||||
export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
|
||||
export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
|
||||
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
|
||||
export const SIEM_TEST_THROTTLER = 'siem-test';
|
||||
|
||||
// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
|
||||
export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
|
||||
@@ -12,5 +11,4 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
|
||||
[OAUTH_REGISTER_THROTTLER]: true,
|
||||
[OAUTH_TOKEN_THROTTLER]: true,
|
||||
[OAUTH_AUTHORIZE_THROTTLER]: true,
|
||||
[SIEM_TEST_THROTTLER]: true,
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
type Props = {
|
||||
destinationName: string;
|
||||
destinationType: string;
|
||||
lastError: string;
|
||||
failingSince: string;
|
||||
settingsLink: string;
|
||||
};
|
||||
|
||||
export const SiemDestinationDisabledEmail = ({
|
||||
destinationName,
|
||||
destinationType,
|
||||
lastError,
|
||||
failingSince,
|
||||
settingsLink,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
Your SIEM destination <strong>{destinationName}</strong> (
|
||||
{destinationType}) has been failing since {failingSince} and was
|
||||
disabled after 24 hours of failed deliveries.
|
||||
</Text>
|
||||
<Text style={paragraph}>Last error: {lastError}</Text>
|
||||
<Text style={paragraph}>
|
||||
Your events are kept and delivery resumes from where it stopped when
|
||||
you re-enable it.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsLink}>View destination</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiemDestinationDisabledEmail;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
type Props = {
|
||||
destinationName: string;
|
||||
destinationType: string;
|
||||
lastError: string;
|
||||
failingSince: string;
|
||||
settingsLink: string;
|
||||
};
|
||||
|
||||
export const SiemDestinationFailingEmail = ({
|
||||
destinationName,
|
||||
destinationType,
|
||||
lastError,
|
||||
failingSince,
|
||||
settingsLink,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
Docmost cannot deliver audit events to your SIEM destination{' '}
|
||||
<strong>{destinationName}</strong> ({destinationType}).
|
||||
</Text>
|
||||
<Text style={paragraph}>Last error: {lastError}</Text>
|
||||
<Text style={paragraph}>Failing since {failingSince}.</Text>
|
||||
<Text style={paragraph}>
|
||||
Docmost keeps retrying every 30 minutes. If the destination is still
|
||||
failing 24 hours after it started, it is disabled automatically.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsLink}>View destination</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiemDestinationFailingEmail;
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
type Props = {
|
||||
destinationName: string;
|
||||
destinationType: string;
|
||||
settingsLink: string;
|
||||
};
|
||||
|
||||
export const SiemDestinationRecoveredEmail = ({
|
||||
destinationName,
|
||||
destinationType,
|
||||
settingsLink,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
Your SIEM destination <strong>{destinationName}</strong> (
|
||||
{destinationType}) is delivering audit events again.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Events buffered during the outage were delivered from where the stream
|
||||
stopped.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsLink}>View destination</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiemDestinationRecoveredEmail;
|
||||
@@ -0,0 +1,18 @@
|
||||
name: docmost-cycle-test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
environment:
|
||||
POSTGRES_DB: docmost_cycle_test
|
||||
POSTGRES_USER: docmost
|
||||
POSTGRES_PASSWORD: docmost
|
||||
ports:
|
||||
- '127.0.0.1:55432:5432'
|
||||
tmpfs:
|
||||
- /var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U docmost -d docmost_cycle_test']
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 15
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".*\\.integration-spec\\.ts$",
|
||||
"transform": {
|
||||
"happy-dom.+\\.js$": [
|
||||
"babel-jest",
|
||||
{
|
||||
"presets": [["@babel/preset-env", { "targets": { "node": "current" } }]]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/../src/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/../src/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/../src/$1",
|
||||
"^@docmost/base-formula/server$": "<rootDir>/../../../packages/base-formula/src/index.server.ts",
|
||||
"^@docmost/base-formula/client$": "<rootDir>/../../../packages/base-formula/src/index.client.ts"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import { CamelCasePlugin, Kysely, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { DbInterface } from '../../src/database/types/db.interface';
|
||||
|
||||
const disposableDatabaseName = 'docmost_cycle_test';
|
||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('TEST_DATABASE_URL must be set for integration tests');
|
||||
}
|
||||
|
||||
export function assertDisposableTestDatabaseUrl(value: string): void {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(value);
|
||||
} catch {
|
||||
throw new Error('TEST_DATABASE_URL must be a valid PostgreSQL URL');
|
||||
}
|
||||
|
||||
const isPostgresUrl = ['postgres:', 'postgresql:'].includes(
|
||||
parsedUrl.protocol,
|
||||
);
|
||||
const isLoopback = ['127.0.0.1', 'localhost', '[::1]'].includes(
|
||||
parsedUrl.hostname,
|
||||
);
|
||||
const databaseName = decodeURIComponent(parsedUrl.pathname.slice(1));
|
||||
|
||||
if (!isPostgresUrl || !isLoopback || databaseName !== disposableDatabaseName) {
|
||||
throw new Error(
|
||||
`Integration tests require the loopback database ${disposableDatabaseName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertDisposableTestDatabaseUrl(databaseUrl);
|
||||
|
||||
const postgresPool = postgres(databaseUrl, { max: 1, onnotice: () => {} });
|
||||
|
||||
export const db = new Kysely<DbInterface>({
|
||||
dialect: new PostgresJSDialect({ postgres: postgresPool }),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
|
||||
let databaseSafetyVerified = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await sql<{ databaseName: string }>`
|
||||
SELECT current_database() AS "databaseName"
|
||||
`.execute(db);
|
||||
|
||||
if (result.rows[0]?.databaseName !== disposableDatabaseName) {
|
||||
throw new Error(
|
||||
`Connected database must be the disposable ${disposableDatabaseName} database`,
|
||||
);
|
||||
}
|
||||
|
||||
databaseSafetyVerified = true;
|
||||
});
|
||||
|
||||
export async function withStatementTimeout<T>(
|
||||
callback: (connection: Kysely<DbInterface>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return db.connection().execute(async (connection) => {
|
||||
await sql`SET statement_timeout = '500ms'`.execute(connection);
|
||||
|
||||
try {
|
||||
return await callback(connection);
|
||||
} finally {
|
||||
await sql`SET statement_timeout = DEFAULT`.execute(connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function truncateFixtureTables(): Promise<void> {
|
||||
if (!databaseSafetyVerified) {
|
||||
throw new Error('Refusing to truncate an unverified integration database');
|
||||
}
|
||||
|
||||
await sql`TRUNCATE TABLE pages, spaces, workspaces CASCADE`.execute(db);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await truncateFixtureTables();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await postgresPool.end();
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { sql } from 'kysely';
|
||||
import { db } from './database';
|
||||
|
||||
export type PageFixture = {
|
||||
id: string;
|
||||
parentPageId: string | null;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type HierarchyContext = {
|
||||
workspaceId: string;
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
async function seedWorkspaceAndSpace(): Promise<HierarchyContext> {
|
||||
const workspace = await db
|
||||
.insertInto('workspaces')
|
||||
.values({
|
||||
hostname: `cycle-test-${randomUUID()}`,
|
||||
name: 'Page hierarchy integration workspace',
|
||||
})
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const space = await db
|
||||
.insertInto('spaces')
|
||||
.values({
|
||||
name: 'Page hierarchy integration space',
|
||||
slug: `cycle-test-${randomUUID()}`,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { spaceId: space.id, workspaceId: workspace.id };
|
||||
}
|
||||
|
||||
async function insertPage(
|
||||
context: HierarchyContext,
|
||||
title: string,
|
||||
parentPageId: string | null = null,
|
||||
): Promise<PageFixture> {
|
||||
const page = await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
parentPageId,
|
||||
slugId: randomUUID(),
|
||||
spaceId: context.spaceId,
|
||||
title,
|
||||
workspaceId: context.workspaceId,
|
||||
})
|
||||
.returning(['id', 'parentPageId', 'title'])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return { id: page.id, parentPageId: page.parentPageId, title };
|
||||
}
|
||||
|
||||
export async function seedAcyclicPageChain(): Promise<{
|
||||
root: PageFixture;
|
||||
child: PageFixture;
|
||||
grandchild: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const root = await insertPage(context, 'Root');
|
||||
const child = await insertPage(context, 'Child', root.id);
|
||||
const grandchild = await insertPage(context, 'Grandchild', child.id);
|
||||
|
||||
return { root, child, grandchild };
|
||||
}
|
||||
|
||||
export async function seedSelfCycle(): Promise<{ self: PageFixture }> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const self = await insertPage(context, 'Self');
|
||||
|
||||
await sql`UPDATE pages SET parent_page_id = ${self.id} WHERE id = ${self.id}`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
return { self: { ...self, parentPageId: self.id } };
|
||||
}
|
||||
|
||||
export async function seedTwoPageCycle(): Promise<{
|
||||
a: PageFixture;
|
||||
b: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const a = await insertPage(context, 'A');
|
||||
const b = await insertPage(context, 'B', a.id);
|
||||
|
||||
await sql`UPDATE pages SET parent_page_id = ${b.id} WHERE id = ${a.id}`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
return {
|
||||
a: { ...a, parentPageId: b.id },
|
||||
b,
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedBranchingDescendantTree(): Promise<{
|
||||
root: PageFixture;
|
||||
firstChild: PageFixture;
|
||||
secondChild: PageFixture;
|
||||
grandchild: PageFixture;
|
||||
}> {
|
||||
const context = await seedWorkspaceAndSpace();
|
||||
const root = await insertPage(context, 'Root');
|
||||
const firstChild = await insertPage(context, 'First child', root.id);
|
||||
const secondChild = await insertPage(context, 'Second child', root.id);
|
||||
const grandchild = await insertPage(context, 'Grandchild', firstChild.id);
|
||||
|
||||
return { root, firstChild, secondChild, grandchild };
|
||||
}
|
||||
Reference in New Issue
Block a user