mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Adding notification settings and slack integration
This commit is contained in:
parent
b1355ae3f8
commit
e58f948e58
37 changed files with 1783 additions and 35 deletions
|
|
@ -1407,6 +1407,15 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
|
|||
initFatal(err, "failed to register host vitals label membership schedule")
|
||||
}
|
||||
|
||||
// Notification deliveries worker — drains pending slack (and future
|
||||
// email) fanout rows written by the service layer when producers upsert
|
||||
// notifications.
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return cron.NewNotificationDeliveriesSchedule(ctx, instanceID, ds, service.PostSlackWebhook, logger)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register notification deliveries schedule")
|
||||
}
|
||||
|
||||
// Start the service that marks activities as completed.
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
return newBatchActivityCompletionCheckerSchedule(ctx, instanceID, ds, logger)
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ const NotificationsModal = ({
|
|||
)}
|
||||
>
|
||||
<div className={`${baseClass}__item-icon`}>
|
||||
<Icon name={severityIcon(n.severity)} />
|
||||
<Icon name={severityIcon(n.severity)} size="small" />
|
||||
</div>
|
||||
<div className={`${baseClass}__item-content`}>
|
||||
<div className={`${baseClass}__item-title`}>{n.title}</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
&__empty {
|
||||
text-align: center;
|
||||
color: $ui-fleet-black-75;
|
||||
padding: 32px 16px;
|
||||
font-size: $x-small;
|
||||
padding: $pad-xlarge $pad-medium;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
|
@ -16,10 +17,12 @@
|
|||
|
||||
&__item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
gap: $pad-small;
|
||||
padding: $pad-small $pad-medium;
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
align-items: flex-start;
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
|
|
@ -40,7 +43,9 @@
|
|||
|
||||
&__item-icon {
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
padding-top: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__item-content {
|
||||
|
|
@ -49,27 +54,32 @@
|
|||
}
|
||||
|
||||
&__item-title {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
margin-bottom: 4px;
|
||||
line-height: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__item-body {
|
||||
color: $ui-fleet-black-75;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
margin-bottom: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__item-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: $pad-medium;
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
font-size: $x-small;
|
||||
padding-top: $pad-small;
|
||||
border-top: 1px solid $ui-fleet-black-10;
|
||||
margin-top: 16px;
|
||||
margin-top: $pad-small;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,16 @@ export interface IGlobalIntegrations extends IZendeskJiraIntegrations {
|
|||
google_calendar?: IGlobalCalendarIntegration[] | null;
|
||||
// whether or not conditional access is enabled for "No team"
|
||||
conditional_access_enabled?: boolean;
|
||||
slack_notifications?: ISlackNotificationsConfig;
|
||||
}
|
||||
|
||||
export interface ISlackNotificationRoute {
|
||||
category: string;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
export interface ISlackNotificationsConfig {
|
||||
routes: ISlackNotificationRoute[];
|
||||
}
|
||||
|
||||
export interface ITeamIntegrations extends IZendeskJiraIntegrations {
|
||||
|
|
|
|||
71
frontend/interfaces/notification_preferences.ts
Normal file
71
frontend/interfaces/notification_preferences.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// NotificationCategory mirrors the Go `fleet.NotificationCategory` enum. Keep
|
||||
// the strings in sync with server/fleet/notification.go.
|
||||
export type NotificationCategory =
|
||||
| "mdm"
|
||||
| "license"
|
||||
| "vulnerabilities"
|
||||
| "policies"
|
||||
| "software"
|
||||
| "hosts"
|
||||
| "integrations"
|
||||
| "system";
|
||||
|
||||
// NotificationChannel mirrors `fleet.NotificationChannel`. Only in_app is
|
||||
// rendered today; email/slack exist so the prefs API can hold state ahead of
|
||||
// the delivery workers landing.
|
||||
export type NotificationChannel = "in_app" | "email" | "slack";
|
||||
|
||||
export interface INotificationPreference {
|
||||
category: NotificationCategory;
|
||||
channel: NotificationChannel;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IListNotificationPreferencesResponse {
|
||||
preferences: INotificationPreference[];
|
||||
}
|
||||
|
||||
export interface IUpdateNotificationPreferencesResponse {
|
||||
preferences: INotificationPreference[];
|
||||
}
|
||||
|
||||
// Human-readable labels for the My Account UI. The Go side only emits stable
|
||||
// identifiers; the UI owns the strings.
|
||||
export const CATEGORY_LABELS: Record<
|
||||
NotificationCategory,
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
mdm: {
|
||||
title: "MDM",
|
||||
description:
|
||||
"APNs / ABM / VPP token expirations, Android Enterprise binding issues.",
|
||||
},
|
||||
license: {
|
||||
title: "License",
|
||||
description: "Fleet license expiration and seat-limit warnings.",
|
||||
},
|
||||
vulnerabilities: {
|
||||
title: "Vulnerabilities",
|
||||
description: "CVEs and CISA KEV matches affecting your fleet.",
|
||||
},
|
||||
policies: {
|
||||
title: "Policies",
|
||||
description: "Critical policies failing across many hosts.",
|
||||
},
|
||||
software: {
|
||||
title: "Software",
|
||||
description: "Install failure spikes and software inventory anomalies.",
|
||||
},
|
||||
hosts: {
|
||||
title: "Hosts",
|
||||
description: "Hosts offline, disk-encryption key escrow issues.",
|
||||
},
|
||||
integrations: {
|
||||
title: "Integrations",
|
||||
description: "Webhook delivery failures and integration health.",
|
||||
},
|
||||
system: {
|
||||
title: "System",
|
||||
description: "Fleet version updates and general system notices.",
|
||||
},
|
||||
};
|
||||
|
|
@ -27,6 +27,7 @@ import CustomLink from "components/CustomLink";
|
|||
|
||||
import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent";
|
||||
import AccountSidePanel from "./AccountSidePanel";
|
||||
import NotificationSettings from "./NotificationSettings";
|
||||
import { getErrorMessage } from "./helpers";
|
||||
|
||||
const baseClass = "account-page";
|
||||
|
|
@ -235,6 +236,7 @@ const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => {
|
|||
serverErrors={errors}
|
||||
smtpConfigured={config?.smtp_settings?.configured || false}
|
||||
/>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
{renderEmailModal()}
|
||||
{renderPasswordModal()}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import React, { useContext, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
|
||||
import notificationsAPI from "services/entities/notifications";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
INotificationPreference,
|
||||
IListNotificationPreferencesResponse,
|
||||
NotificationCategory,
|
||||
} from "interfaces/notification_preferences";
|
||||
|
||||
// @ts-ignore
|
||||
import Slider from "components/forms/fields/Slider";
|
||||
import Spinner from "components/Spinner";
|
||||
import DataError from "components/DataError";
|
||||
|
||||
const baseClass = "notification-settings";
|
||||
|
||||
const PREFERENCES_QUERY_KEY = ["notification-preferences"] as const;
|
||||
|
||||
const NotificationSettings = (): JSX.Element => {
|
||||
const queryClient = useQueryClient();
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<
|
||||
IListNotificationPreferencesResponse,
|
||||
Error
|
||||
>(PREFERENCES_QUERY_KEY, () => notificationsAPI.getPreferences(), {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Index the full preference grid by category so a toggle click can flip the
|
||||
// in_app row without disturbing the other channels' rows.
|
||||
const byCategory = useMemo(() => {
|
||||
const map = new Map<NotificationCategory, INotificationPreference[]>();
|
||||
(data?.preferences ?? []).forEach((p) => {
|
||||
const list = map.get(p.category) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.category, list);
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
const updateMutation = useMutation(
|
||||
(prefs: INotificationPreference[]) =>
|
||||
notificationsAPI.updatePreferences(prefs),
|
||||
{
|
||||
// Optimistic update: replace the cache with the outgoing prefs so the
|
||||
// toggle flips immediately. If the server rejects, we invalidate on
|
||||
// error to roll back to the true state.
|
||||
onMutate: async (prefs) => {
|
||||
await queryClient.cancelQueries(PREFERENCES_QUERY_KEY);
|
||||
const prev = queryClient.getQueryData<IListNotificationPreferencesResponse>(
|
||||
PREFERENCES_QUERY_KEY
|
||||
);
|
||||
queryClient.setQueryData<IListNotificationPreferencesResponse>(
|
||||
PREFERENCES_QUERY_KEY,
|
||||
{ preferences: prefs }
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
queryClient.setQueryData(PREFERENCES_QUERY_KEY, ctx.prev);
|
||||
}
|
||||
renderFlash("error", "Could not update notification preferences.");
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
// Server is the authority; replace cache with its response.
|
||||
queryClient.setQueryData(PREFERENCES_QUERY_KEY, res);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const toggleInApp = (category: NotificationCategory, currentlyEnabled: boolean) => {
|
||||
const all = data?.preferences ?? [];
|
||||
const next = all.map((p) =>
|
||||
p.category === category && p.channel === "in_app"
|
||||
? { ...p, enabled: !currentlyEnabled }
|
||||
: p
|
||||
);
|
||||
updateMutation.mutate(next);
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <DataError />;
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<h2>Notifications</h2>
|
||||
<p className={`${baseClass}__description`}>
|
||||
Choose the types of notifications that should appear in your in-app
|
||||
notification center.
|
||||
</p>
|
||||
<ul className={`${baseClass}__list`}>
|
||||
{Object.entries(CATEGORY_LABELS).map(([categoryKey, label]) => {
|
||||
const category = categoryKey as NotificationCategory;
|
||||
const prefs = byCategory.get(category) ?? [];
|
||||
const inAppPref = prefs.find((p) => p.channel === "in_app");
|
||||
const enabled = inAppPref?.enabled ?? true;
|
||||
return (
|
||||
<li key={category} className={`${baseClass}__item`}>
|
||||
<Slider
|
||||
value={enabled}
|
||||
onChange={() => toggleInApp(category, enabled)}
|
||||
inactiveText="Off"
|
||||
activeText="On"
|
||||
disabled={updateMutation.isLoading}
|
||||
/>
|
||||
<div className={`${baseClass}__item-text`}>
|
||||
<div className={`${baseClass}__item-title`}>{label.title}</div>
|
||||
<div className={`${baseClass}__item-description`}>
|
||||
{label.description}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSettings;
|
||||
69
frontend/pages/AccountPage/NotificationSettings/_styles.scss
Normal file
69
frontend/pages/AccountPage/NotificationSettings/_styles.scss
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
.notification-settings {
|
||||
// Establish the page baseline so any descendant without an explicit
|
||||
// font-size (e.g. list markup, inline spans) stays at 14px rather than
|
||||
// inheriting the global 16px body size.
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
margin-top: $pad-large;
|
||||
|
||||
h2 {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
line-height: 16px;
|
||||
margin: 0 0 $pad-xsmall;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $xx-small;
|
||||
line-height: 16px;
|
||||
margin: 0 0 $pad-small;
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $pad-small;
|
||||
padding: $pad-small $pad-medium;
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// FormField wraps the Slider and the global `.form-field` rule sets
|
||||
// `width: 100%`, which stretches it across the row and pushes the text
|
||||
// to the far right. Pin the slider column to the button+label width so
|
||||
// titles line up in a single column and the text block gets the rest.
|
||||
.form-field--slider {
|
||||
flex: 0 0 65px;
|
||||
width: 65px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__item-title {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&__item-description {
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $xx-small;
|
||||
line-height: 16px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
1
frontend/pages/AccountPage/NotificationSettings/index.ts
Normal file
1
frontend/pages/AccountPage/NotificationSettings/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./NotificationSettings";
|
||||
|
|
@ -10,6 +10,7 @@ import ConditionalAccess from "./cards/ConditionalAccess";
|
|||
import IdentityProviders from "./cards/IdentityProviders";
|
||||
import Sso from "./cards/Sso";
|
||||
import GlobalHostStatusWebhook from "../IntegrationsPage/cards/GlobalHostStatusWebhook";
|
||||
import SlackNotifications from "./cards/SlackNotifications";
|
||||
|
||||
const getIntegrationSettingsNavItems = (): ISideNavItem<any>[] => {
|
||||
const items: ISideNavItem<any>[] = [
|
||||
|
|
@ -67,6 +68,12 @@ const getIntegrationSettingsNavItems = (): ISideNavItem<any>[] => {
|
|||
path: PATHS.ADMIN_INTEGRATIONS_CONDITIONAL_ACCESS,
|
||||
Card: ConditionalAccess,
|
||||
},
|
||||
{
|
||||
title: "Slack notifications",
|
||||
urlSection: "slack-notifications",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_SLACK_NOTIFICATIONS,
|
||||
Card: SlackNotifications,
|
||||
},
|
||||
];
|
||||
|
||||
return items;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import React, { useContext, useMemo, useState } from "react";
|
||||
|
||||
import SettingsSection from "pages/admin/components/SettingsSection";
|
||||
import PageDescription from "components/PageDescription";
|
||||
import Button from "components/buttons/Button";
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Icon from "components/Icon/Icon";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import configAPI from "services/entities/config";
|
||||
import {
|
||||
ISlackNotificationRoute,
|
||||
} from "interfaces/integration";
|
||||
|
||||
import { IAppConfigFormProps } from "pages/admin/OrgSettingsPage/cards/constants";
|
||||
|
||||
const baseClass = "slack-notifications";
|
||||
|
||||
// Mirrors server/fleet/notification.go AllNotificationCategories, plus the
|
||||
// route-only wildcard "all" at the top for admins who want one webhook that
|
||||
// receives every Fleet notification.
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ label: "All categories", value: "all" },
|
||||
{ label: "MDM", value: "mdm" },
|
||||
{ label: "License", value: "license" },
|
||||
{ label: "Vulnerabilities", value: "vulnerabilities" },
|
||||
{ label: "Policies", value: "policies" },
|
||||
{ label: "Software", value: "software" },
|
||||
{ label: "Hosts", value: "hosts" },
|
||||
{ label: "Integrations", value: "integrations" },
|
||||
{ label: "System", value: "system" },
|
||||
];
|
||||
|
||||
const WEBHOOK_PREFIX = "https://hooks.slack.com/";
|
||||
|
||||
const emptyRoute = (): ISlackNotificationRoute => ({
|
||||
category: "vulnerabilities",
|
||||
webhook_url: "",
|
||||
});
|
||||
|
||||
const SlackNotifications = ({
|
||||
appConfig,
|
||||
}: IAppConfigFormProps): JSX.Element => {
|
||||
const { renderFlash } = useContext(NotificationContext);
|
||||
|
||||
const initialRoutes = useMemo<ISlackNotificationRoute[]>(
|
||||
() =>
|
||||
appConfig.integrations.slack_notifications?.routes?.map((r) => ({
|
||||
...r,
|
||||
})) ?? [],
|
||||
[appConfig]
|
||||
);
|
||||
|
||||
const [routes, setRoutes] = useState<ISlackNotificationRoute[]>(
|
||||
initialRoutes
|
||||
);
|
||||
const [rowErrors, setRowErrors] = useState<Record<number, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const addRoute = () => setRoutes((prev) => [...prev, emptyRoute()]);
|
||||
|
||||
const removeRoute = (idx: number) =>
|
||||
setRoutes((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
const updateRoute = (
|
||||
idx: number,
|
||||
patch: Partial<ISlackNotificationRoute>
|
||||
) => {
|
||||
setRoutes((prev) =>
|
||||
prev.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
||||
);
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: Record<number, string> = {};
|
||||
const seen = new Set<string>();
|
||||
routes.forEach((r, i) => {
|
||||
const url = r.webhook_url.trim();
|
||||
if (!url.startsWith(WEBHOOK_PREFIX)) {
|
||||
errs[i] = `Webhook URL must start with ${WEBHOOK_PREFIX}`;
|
||||
return;
|
||||
}
|
||||
const key = `${r.category}|${url}`;
|
||||
if (seen.has(key)) {
|
||||
errs[i] = "Duplicate (category, webhook URL) row";
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
});
|
||||
setRowErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
|
||||
const onSave = async (evt: React.MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
if (!validate()) return;
|
||||
const trimmedRoutes = routes.map((r) => ({
|
||||
category: r.category,
|
||||
webhook_url: r.webhook_url.trim(),
|
||||
}));
|
||||
|
||||
// Bypass the shared deepDifference-driven save used by the rest of the
|
||||
// Integrations page: for arrays, deepDifference uses lodash's
|
||||
// differenceWith which only returns elements present in the new value
|
||||
// but not the old, collapsing an added/edited row into a partial array
|
||||
// and replacing the stored routes with just the delta. Sending the
|
||||
// full routes array directly is the simplest fix and keeps the Slack
|
||||
// card's behavior independent of future refactors to that utility.
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await configAPI.update({
|
||||
integrations: {
|
||||
slack_notifications: { routes: trimmedRoutes },
|
||||
},
|
||||
});
|
||||
renderFlash("success", "Slack notification routes saved.");
|
||||
} catch (err: unknown) {
|
||||
renderFlash("error", "Could not save Slack notification routes.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Slack notifications" className={baseClass}>
|
||||
<PageDescription content="Route in-app notifications to Slack by category. Each row posts Fleet's system notifications for the selected category to its incoming-webhook URL." />
|
||||
|
||||
<div className={`${baseClass}__routes`}>
|
||||
{routes.length === 0 && (
|
||||
<p className={`${baseClass}__empty`}>
|
||||
No routes configured. Add one below to start delivering to Slack.
|
||||
</p>
|
||||
)}
|
||||
{routes.map((r, idx) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={idx} className={`${baseClass}__row`}>
|
||||
<Dropdown
|
||||
className={`${baseClass}__category`}
|
||||
label={idx === 0 ? "Category" : undefined}
|
||||
options={CATEGORY_OPTIONS}
|
||||
value={r.category}
|
||||
onChange={(value: string) =>
|
||||
updateRoute(idx, { category: value })
|
||||
}
|
||||
searchable={false}
|
||||
/>
|
||||
<InputField
|
||||
label={idx === 0 ? "Slack incoming-webhook URL" : undefined}
|
||||
placeholder={WEBHOOK_PREFIX}
|
||||
value={r.webhook_url}
|
||||
onChange={(value: string) =>
|
||||
updateRoute(idx, { webhook_url: value })
|
||||
}
|
||||
error={rowErrors[idx]}
|
||||
name={`slack-webhook-${idx}`}
|
||||
/>
|
||||
<Button
|
||||
variant="text-icon"
|
||||
onClick={() => removeRoute(idx)}
|
||||
className={`${baseClass}__remove`}
|
||||
>
|
||||
<Icon name="close" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`${baseClass}__actions`}>
|
||||
<Button variant="text-icon" onClick={addRoute}>
|
||||
<Icon name="plus" />
|
||||
Add route
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlackNotifications;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
.slack-notifications {
|
||||
&__empty {
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
margin: 0 0 $pad-medium;
|
||||
}
|
||||
|
||||
&__routes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-medium;
|
||||
margin-bottom: $pad-medium;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr auto;
|
||||
gap: $pad-medium;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
&__category {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
// Align remove button with the input baseline when the first row shows
|
||||
// field labels above it.
|
||||
padding-bottom: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: $pad-medium;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./SlackNotifications";
|
||||
|
|
@ -101,7 +101,7 @@ const NotificationsPage = ({
|
|||
})}
|
||||
>
|
||||
<div className={`${baseClass}__item-icon`}>
|
||||
<Icon name={severityIcon(n.severity)} />
|
||||
<Icon name={severityIcon(n.severity)} size="small" />
|
||||
</div>
|
||||
<div className={`${baseClass}__item-content`}>
|
||||
<div className={`${baseClass}__item-title`}>{n.title}</div>
|
||||
|
|
|
|||
|
|
@ -3,18 +3,21 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: $pad-medium;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 48px 16px;
|
||||
padding: $pad-xlarge $pad-medium;
|
||||
text-align: center;
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $x-small;
|
||||
}
|
||||
|
||||
&__list {
|
||||
|
|
@ -28,10 +31,12 @@
|
|||
|
||||
&__item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
gap: $pad-small;
|
||||
padding: $pad-small $pad-medium;
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
align-items: flex-start;
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
|
|
@ -56,7 +61,9 @@
|
|||
|
||||
&__item-icon {
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
padding-top: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__item-content {
|
||||
|
|
@ -65,18 +72,22 @@
|
|||
}
|
||||
|
||||
&__item-title {
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
margin-bottom: 4px;
|
||||
line-height: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__item-body {
|
||||
color: $ui-fleet-black-75;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
font-size: $x-small;
|
||||
line-height: 16px;
|
||||
margin-bottom: $pad-xsmall;
|
||||
}
|
||||
|
||||
&__item-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: $pad-medium;
|
||||
font-size: $x-small;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export default {
|
|||
ADMIN_INTEGRATIONS_CALENDARS: `${INTEGRATIONS_PREFIX}/calendars`,
|
||||
ADMIN_INTEGRATIONS_CHANGE_MANAGEMENT: `${INTEGRATIONS_PREFIX}/change-management`,
|
||||
ADMIN_INTEGRATIONS_CONDITIONAL_ACCESS: `${INTEGRATIONS_PREFIX}/conditional-access`,
|
||||
ADMIN_INTEGRATIONS_SLACK_NOTIFICATIONS: `${INTEGRATIONS_PREFIX}/slack-notifications`,
|
||||
ADMIN_INTEGRATIONS_CERTIFICATE_AUTHORITIES: `${INTEGRATIONS_PREFIX}/certificate-authorities`,
|
||||
ADMIN_INTEGRATIONS_IDENTITY_PROVIDER: `${INTEGRATIONS_PREFIX}/identity-provider`,
|
||||
ADMIN_INTEGRATIONS_VPP: `${INTEGRATIONS_PREFIX}/mdm/vpp`,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import {
|
|||
IListNotificationsResponse,
|
||||
INotificationSummaryResponse,
|
||||
} from "interfaces/notification_center";
|
||||
import {
|
||||
INotificationPreference,
|
||||
IListNotificationPreferencesResponse,
|
||||
IUpdateNotificationPreferencesResponse,
|
||||
} from "interfaces/notification_preferences";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
|
||||
export interface IListNotificationsParams {
|
||||
|
|
@ -50,4 +55,16 @@ export default {
|
|||
markAllRead: (): Promise<Record<string, never>> => {
|
||||
return sendRequest("POST", endpoints.NOTIFICATIONS_READ_ALL);
|
||||
},
|
||||
|
||||
getPreferences: (): Promise<IListNotificationPreferencesResponse> => {
|
||||
return sendRequest("GET", endpoints.NOTIFICATION_PREFERENCES);
|
||||
},
|
||||
|
||||
updatePreferences: (
|
||||
preferences: INotificationPreference[]
|
||||
): Promise<IUpdateNotificationPreferencesResponse> => {
|
||||
return sendRequest("PUT", endpoints.NOTIFICATION_PREFERENCES, {
|
||||
preferences,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export default {
|
|||
NOTIFICATION_READ: (id: number) =>
|
||||
`/${API_VERSION}/fleet/notifications/${id}/read`,
|
||||
NOTIFICATIONS_READ_ALL: `/${API_VERSION}/fleet/notifications/read_all`,
|
||||
NOTIFICATION_PREFERENCES: `/${API_VERSION}/fleet/notifications/preferences`,
|
||||
|
||||
LOGIN: `/${API_VERSION}/fleet/login`,
|
||||
CREATE_SESSION: `/${API_VERSION}/fleet/sessions`,
|
||||
|
|
|
|||
106
server/cron/notification_deliveries_cron.go
Normal file
106
server/cron/notification_deliveries_cron.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/service/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
notificationDeliveriesBatch = 50
|
||||
// Pick a periodicity that feels snappy for ops without hammering the DB.
|
||||
// Each tick is one SELECT + a handful of HTTP POSTs against webhooks.
|
||||
notificationDeliveriesPeriodicity = 1 * time.Minute
|
||||
)
|
||||
|
||||
// slackSender is an injection point so the cron can be exercised in tests
|
||||
// without hitting real Slack webhooks. The default uses PostSlackWebhook in
|
||||
// the service package (wired at startup via NewNotificationDeliveriesSchedule).
|
||||
//
|
||||
// serverBaseURL is threaded through so the deliverer can absolutize any
|
||||
// relative CTA URLs on the notification — Slack rejects Block Kit buttons
|
||||
// whose url field isn't absolute.
|
||||
type slackSender func(ctx context.Context, url string, n *fleet.Notification, serverBaseURL string) error
|
||||
|
||||
// NewNotificationDeliveriesSchedule returns the cron that drains pending
|
||||
// notification_deliveries rows — today just Slack, but the dispatcher is
|
||||
// channel-keyed so email/slack/future channels all share the same plumbing.
|
||||
func NewNotificationDeliveriesSchedule(
|
||||
ctx context.Context,
|
||||
instanceID string,
|
||||
ds fleet.Datastore,
|
||||
sendSlack slackSender,
|
||||
logger *slog.Logger,
|
||||
) (*schedule.Schedule, error) {
|
||||
name := string(fleet.CronNotificationDeliveries)
|
||||
logger = logger.With("cron", name)
|
||||
s := schedule.New(
|
||||
ctx, name, instanceID, notificationDeliveriesPeriodicity, ds, ds,
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob(
|
||||
"deliver_slack",
|
||||
func(ctx context.Context) error {
|
||||
return processPendingSlackDeliveries(ctx, ds, sendSlack, logger)
|
||||
},
|
||||
),
|
||||
)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// processPendingSlackDeliveries claims a batch of pending slack deliveries
|
||||
// and dispatches each to its webhook URL. Failures are recorded on the
|
||||
// delivery row rather than returned, so one bad webhook doesn't poison the
|
||||
// batch — a single totally-unreachable integration shouldn't silence the
|
||||
// rest of the fleet's deliveries.
|
||||
func processPendingSlackDeliveries(
|
||||
ctx context.Context, ds fleet.Datastore, sendSlack slackSender, logger *slog.Logger,
|
||||
) error {
|
||||
deliveries, notifs, err := ds.ClaimPendingDeliveries(ctx, fleet.NotificationChannelSlack, notificationDeliveriesBatch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim pending deliveries: %w", err)
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load the server URL once per batch so the deliverer can absolutize
|
||||
// relative CTA paths on the notification. A missing server URL isn't
|
||||
// fatal — the deliverer just drops the CTA button.
|
||||
var serverBaseURL string
|
||||
if cfg, err := ds.AppConfig(ctx); err != nil {
|
||||
logger.WarnContext(ctx, "load app config for slack server_url", "err", err)
|
||||
} else {
|
||||
serverBaseURL = cfg.ServerSettings.ServerURL
|
||||
}
|
||||
|
||||
for _, d := range deliveries {
|
||||
notif, ok := notifs[d.NotificationID]
|
||||
if !ok {
|
||||
// The notification got deleted between enqueue and dispatch
|
||||
// (cascade or manual cleanup). Mark failed with a stable reason
|
||||
// so it doesn't re-queue; there's nothing to send.
|
||||
if err := ds.MarkDeliveryResult(ctx, d.ID, fleet.NotificationDeliveryStatusFailed,
|
||||
"source notification no longer exists"); err != nil {
|
||||
logger.ErrorContext(ctx, "mark delivery failed", "delivery_id", d.ID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := sendSlack(ctx, d.Target, notif, serverBaseURL); err != nil {
|
||||
logger.WarnContext(ctx, "slack delivery failed",
|
||||
"delivery_id", d.ID, "notification_id", d.NotificationID, "err", err)
|
||||
if mErr := ds.MarkDeliveryResult(ctx, d.ID, fleet.NotificationDeliveryStatusFailed, err.Error()); mErr != nil {
|
||||
logger.ErrorContext(ctx, "mark delivery failed", "delivery_id", d.ID, "err", mErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := ds.MarkDeliveryResult(ctx, d.ID, fleet.NotificationDeliveryStatusSent, ""); err != nil {
|
||||
logger.ErrorContext(ctx, "mark delivery sent", "delivery_id", d.ID, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260416161724, Down_20260416161724)
|
||||
}
|
||||
|
||||
// Up_20260416161724 creates the user_notification_preferences table backing
|
||||
// per-user opt-in/out for notification categories across delivery channels.
|
||||
//
|
||||
// Rows only exist for explicit opt-out (or later re-opt-in), so the SELECT
|
||||
// side of the filter is a LEFT JOIN that treats a missing row as "enabled".
|
||||
// This keeps the default behavior "user sees everything in their audience"
|
||||
// without needing to seed rows for every (user, category, channel) cross
|
||||
// product at user creation time.
|
||||
//
|
||||
// channel is stored even though only in_app is read today so the UI can hold
|
||||
// user preferences ahead of the email/slack delivery pipelines landing.
|
||||
func Up_20260416161724(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE user_notification_preferences (
|
||||
user_id INT(10) UNSIGNED NOT NULL,
|
||||
category VARCHAR(32) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
channel VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (user_id, category, channel),
|
||||
CONSTRAINT fk_unp_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`); err != nil {
|
||||
return fmt.Errorf("creating user_notification_preferences table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260416161724(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260416161724(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
userResult, err := db.Exec(`
|
||||
INSERT INTO users (name, email, password, salt, global_role)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
"Admin User", "admin@example.com", []byte("pw"), "salt", "admin",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
userID, err := userResult.LastInsertId()
|
||||
require.NoError(t, err)
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
// Insert a preference row.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO user_notification_preferences (user_id, category, channel, enabled)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
userID, "vulnerabilities", "in_app", false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// PK (user_id, category, channel) rejects exact duplicates.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO user_notification_preferences (user_id, category, channel, enabled)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
userID, "vulnerabilities", "in_app", true,
|
||||
)
|
||||
require.Error(t, err, "(user_id, category, channel) must be unique")
|
||||
|
||||
// Different channel is fine.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO user_notification_preferences (user_id, category, channel, enabled)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
userID, "vulnerabilities", "email", true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var rowCount int
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = ?`, userID).Scan(&rowCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, rowCount)
|
||||
|
||||
// Deleting the user cascades.
|
||||
_, err = db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = ?`, userID).Scan(&rowCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, rowCount)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260416163704, Down_20260416163704)
|
||||
}
|
||||
|
||||
// Up_20260416163704 adds a unique index on
|
||||
// (notification_id, channel, target) to notification_deliveries.
|
||||
//
|
||||
// Producers upsert notifications on a cron and then fan out one delivery row
|
||||
// per destination (channel+target). Without this index, running the same cron
|
||||
// twice would double-send to Slack. The index lets the fanout use INSERT
|
||||
// IGNORE and be naturally idempotent per destination.
|
||||
//
|
||||
// For future per-user channels (email), the target will be the user's email
|
||||
// so the same index protects against duplicates too.
|
||||
func Up_20260416163704(tx *sql.Tx) error {
|
||||
// target is VARCHAR(512) utf8mb4 — 512*4 = 2048 bytes which exceeds
|
||||
// InnoDB's default 767-byte index key limit without innodb_large_prefix.
|
||||
// We index a prefix of target that still uniquely identifies the
|
||||
// destination for realistic webhook URLs and email addresses. 191 chars
|
||||
// is the well-known safe prefix length for utf8mb4 column indexes.
|
||||
if _, err := tx.Exec(`
|
||||
ALTER TABLE notification_deliveries
|
||||
ADD UNIQUE KEY uq_nd_notification_channel_target (notification_id, channel, target(191))
|
||||
`); err != nil {
|
||||
return fmt.Errorf("adding unique index on notification_deliveries: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260416163704(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260416163704(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// A notification to hang deliveries off of.
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO notifications (type, severity, title, body, dedupe_key, audience)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
"license_expiring", "warning", "lic", "body", "license_expiring", "admin",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
var notifID int64
|
||||
require.NoError(t, db.QueryRow(`SELECT id FROM notifications WHERE dedupe_key = ?`, "license_expiring").Scan(¬ifID))
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
// Two inserts with the same (notification_id, channel, target) — second
|
||||
// should fail the unique key, proving idempotent fanout works.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO notification_deliveries (notification_id, channel, target, status)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
notifID, "slack", "https://hooks.slack.com/services/AAA", "pending",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO notification_deliveries (notification_id, channel, target, status)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
notifID, "slack", "https://hooks.slack.com/services/AAA", "pending",
|
||||
)
|
||||
require.Error(t, err, "duplicate (notification_id, channel, target) must be rejected")
|
||||
|
||||
// Different target is allowed.
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO notification_deliveries (notification_id, channel, target, status)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
notifID, "slack", "https://hooks.slack.com/services/BBB", "pending",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
|
|
@ -82,6 +84,35 @@ func (ds *Datastore) ResolveNotification(ctx context.Context, dedupeKey string)
|
|||
return nil
|
||||
}
|
||||
|
||||
// disabledTypesForUserInApp returns the set of NotificationTypes the user has
|
||||
// silenced for the in-app channel. It looks up the user's opt-out rows and
|
||||
// expands them to types via fleet.NotificationTypeCategory. An unknown type
|
||||
// (one not in the registry) is never filtered here — we default to showing
|
||||
// unknown notifications so a newly-added producer without a category mapping
|
||||
// is still visible.
|
||||
func (ds *Datastore) disabledTypesForUserInApp(ctx context.Context, userID uint) ([]fleet.NotificationType, error) {
|
||||
prefs, err := ds.ListUserNotificationPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
disabledCats := make(map[fleet.NotificationCategory]struct{}, len(prefs))
|
||||
for _, p := range prefs {
|
||||
if p.Channel == fleet.NotificationChannelInApp && !p.Enabled {
|
||||
disabledCats[p.Category] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(disabledCats) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var disabledTypes []fleet.NotificationType
|
||||
for t, c := range fleet.NotificationTypeCategory {
|
||||
if _, ok := disabledCats[c]; ok {
|
||||
disabledTypes = append(disabledTypes, t)
|
||||
}
|
||||
}
|
||||
return disabledTypes, nil
|
||||
}
|
||||
|
||||
// audienceForUser returns the list of notification audiences a user should
|
||||
// see. For v1 this is just "admin" if the user is a global admin, else empty.
|
||||
func audienceForUser(user *fleet.User) []fleet.NotificationAudience {
|
||||
|
|
@ -109,6 +140,11 @@ func (ds *Datastore) ListNotificationsForUser(
|
|||
return []*fleet.Notification{}, nil
|
||||
}
|
||||
|
||||
disabledTypes, err := ds.disabledTypesForUserInApp(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "load user notification preferences")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT n.id, n.type, n.severity, n.title, n.body, n.cta_url, n.cta_label,
|
||||
COALESCE(n.metadata, CAST('null' AS JSON)) AS metadata,
|
||||
|
|
@ -126,6 +162,10 @@ func (ds *Datastore) ListNotificationsForUser(
|
|||
if !filter.IncludeDismissed {
|
||||
query += ` AND uns.dismissed_at IS NULL`
|
||||
}
|
||||
if len(disabledTypes) > 0 {
|
||||
query += ` AND n.type NOT IN (?)`
|
||||
args = append(args, disabledTypes)
|
||||
}
|
||||
query += ` ORDER BY
|
||||
FIELD(n.severity, 'error', 'warning', 'info'),
|
||||
n.created_at DESC`
|
||||
|
|
@ -286,7 +326,12 @@ func (ds *Datastore) CountActiveNotificationsForUser(
|
|||
return 0, 0, nil
|
||||
}
|
||||
|
||||
stmt, args, err := sqlx.In(`
|
||||
disabledTypes, err := ds.disabledTypesForUserInApp(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, 0, ctxerr.Wrap(ctx, err, "load user notification preferences")
|
||||
}
|
||||
|
||||
q := `
|
||||
SELECT
|
||||
SUM(CASE WHEN uns.read_at IS NULL THEN 1 ELSE 0 END) AS unread,
|
||||
COUNT(*) AS active
|
||||
|
|
@ -295,8 +340,13 @@ func (ds *Datastore) CountActiveNotificationsForUser(
|
|||
ON uns.notification_id = n.id AND uns.user_id = ?
|
||||
WHERE n.audience IN (?)
|
||||
AND n.resolved_at IS NULL
|
||||
AND (uns.dismissed_at IS NULL)
|
||||
`, userID, audiences)
|
||||
AND (uns.dismissed_at IS NULL)`
|
||||
qArgs := []interface{}{userID, audiences}
|
||||
if len(disabledTypes) > 0 {
|
||||
q += ` AND n.type NOT IN (?)`
|
||||
qArgs = append(qArgs, disabledTypes)
|
||||
}
|
||||
stmt, args, err := sqlx.In(q, qArgs...)
|
||||
if err != nil {
|
||||
return 0, 0, ctxerr.Wrap(ctx, err, "build notification count query")
|
||||
}
|
||||
|
|
@ -310,3 +360,172 @@ func (ds *Datastore) CountActiveNotificationsForUser(
|
|||
}
|
||||
return int(row.Unread.Int64), int(row.Active.Int64), nil
|
||||
}
|
||||
|
||||
// ListUserNotificationPreferences returns the opt-out rows for the given user.
|
||||
// Rows not present default to "enabled" so the caller fills defaults itself
|
||||
// when it needs a complete grid.
|
||||
func (ds *Datastore) ListUserNotificationPreferences(
|
||||
ctx context.Context, userID uint,
|
||||
) ([]fleet.UserNotificationPreference, error) {
|
||||
var rows []fleet.UserNotificationPreference
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows,
|
||||
`SELECT user_id, category, channel, enabled
|
||||
FROM user_notification_preferences
|
||||
WHERE user_id = ?`,
|
||||
userID,
|
||||
); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list user notification preferences")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// UpsertUserNotificationPreferences writes the provided preferences for the
|
||||
// given user. To keep storage minimal, an Enabled=true row is deleted rather
|
||||
// than stored — the default is enabled, so the absence of a row encodes it.
|
||||
// Enabled=false rows are upserted.
|
||||
func (ds *Datastore) UpsertUserNotificationPreferences(
|
||||
ctx context.Context, userID uint, prefs []fleet.UserNotificationPreference,
|
||||
) error {
|
||||
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||||
for _, p := range prefs {
|
||||
if p.Enabled {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM user_notification_preferences
|
||||
WHERE user_id = ? AND category = ? AND channel = ?`,
|
||||
userID, p.Category, p.Channel,
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "delete user notification preference")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO user_notification_preferences (user_id, category, channel, enabled)
|
||||
VALUES (?, ?, ?, 0)
|
||||
ON DUPLICATE KEY UPDATE enabled = 0, updated_at = CURRENT_TIMESTAMP(6)`,
|
||||
userID, p.Category, p.Channel,
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "upsert user notification preference")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// EnqueueNotificationDelivery inserts a pending delivery row. The unique
|
||||
// index uq_nd_notification_channel_target makes this a no-op for duplicates,
|
||||
// so producers can fan out on every cron tick without double-scheduling.
|
||||
func (ds *Datastore) EnqueueNotificationDelivery(
|
||||
ctx context.Context, notificationID uint, channel fleet.NotificationChannel, target string,
|
||||
) error {
|
||||
_, err := ds.writer(ctx).ExecContext(ctx,
|
||||
`INSERT IGNORE INTO notification_deliveries (notification_id, channel, target, status)
|
||||
VALUES (?, ?, ?, 'pending')`,
|
||||
notificationID, channel, target,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueue notification delivery")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClaimPendingDeliveries atomically marks up to `limit` pending delivery rows
|
||||
// for the given channel as in-flight ("sending") and returns them along with
|
||||
// the referenced notification rows. A separate "sending" state is used so
|
||||
// multiple worker instances running this cron in parallel don't grab the
|
||||
// same rows — whoever wins the UPDATE owns the rows until they mark them
|
||||
// sent/failed.
|
||||
func (ds *Datastore) ClaimPendingDeliveries(
|
||||
ctx context.Context, channel fleet.NotificationChannel, limit int,
|
||||
) ([]*fleet.NotificationDelivery, map[uint]*fleet.Notification, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
claimID := time.Now().UnixNano()
|
||||
|
||||
// Stage 1: claim a batch by stamping a unique marker into `error` so we
|
||||
// can re-select exactly what we just took. `error` is otherwise NULL for
|
||||
// pending rows; this piggybacks on the existing column without needing
|
||||
// another schema change. The marker is cleared on MarkDeliveryResult.
|
||||
marker := fmt.Sprintf("claim:%d", claimID)
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE notification_deliveries
|
||||
SET status = 'sending', error = ?, attempted_at = CURRENT_TIMESTAMP(6)
|
||||
WHERE channel = ? AND status = 'pending'
|
||||
ORDER BY id
|
||||
LIMIT ?`,
|
||||
marker, channel, limit,
|
||||
); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "claim pending deliveries")
|
||||
}
|
||||
|
||||
// Stage 2: read back the claimed rows.
|
||||
var rows []*fleet.NotificationDelivery
|
||||
if err := sqlx.SelectContext(ctx, ds.writer(ctx), &rows, `
|
||||
SELECT id, notification_id, channel, target, status, error, attempted_at, created_at, updated_at
|
||||
FROM notification_deliveries
|
||||
WHERE channel = ? AND status = 'sending' AND error = ?`,
|
||||
channel, marker,
|
||||
); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "load claimed deliveries")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Stage 3: bulk-fetch the notifications those deliveries reference so the
|
||||
// caller doesn't issue N+1 queries.
|
||||
ids := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]struct{}, len(rows))
|
||||
for _, r := range rows {
|
||||
if _, ok := seen[r.NotificationID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.NotificationID] = struct{}{}
|
||||
ids = append(ids, r.NotificationID)
|
||||
}
|
||||
stmt, args, err := sqlx.In(`
|
||||
SELECT id, type, severity, title, body, cta_url, cta_label,
|
||||
COALESCE(metadata, CAST('null' AS JSON)) AS metadata,
|
||||
dedupe_key, audience, resolved_at, created_at, updated_at
|
||||
FROM notifications
|
||||
WHERE id IN (?)`, ids)
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "build notifications lookup for deliveries")
|
||||
}
|
||||
var notifs []*fleet.Notification
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), ¬ifs, stmt, args...); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "load notifications for deliveries")
|
||||
}
|
||||
byID := make(map[uint]*fleet.Notification, len(notifs))
|
||||
for _, n := range notifs {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
return rows, byID, nil
|
||||
}
|
||||
|
||||
// MarkDeliveryResult records the outcome of a send attempt. status == sent
|
||||
// clears the error column; status == failed stores the message truncated to
|
||||
// a safe length so a long error payload doesn't blow up the row.
|
||||
func (ds *Datastore) MarkDeliveryResult(
|
||||
ctx context.Context, deliveryID uint, status fleet.NotificationDeliveryStatus, errMsg string,
|
||||
) error {
|
||||
const maxErrLen = 4000
|
||||
if len(errMsg) > maxErrLen {
|
||||
errMsg = errMsg[:maxErrLen]
|
||||
}
|
||||
var errCol interface{}
|
||||
if errMsg == "" {
|
||||
errCol = nil
|
||||
} else {
|
||||
errCol = errMsg
|
||||
}
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE notification_deliveries
|
||||
SET status = ?, error = ?, attempted_at = CURRENT_TIMESTAMP(6)
|
||||
WHERE id = ?`,
|
||||
status, errCol, deliveryID,
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "mark delivery result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,3 +97,27 @@ type CreateDemoNotificationResponse struct {
|
|||
}
|
||||
|
||||
func (r CreateDemoNotificationResponse) Error() error { return r.Err }
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Per-user notification preferences
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type ListNotificationPreferencesRequest struct{}
|
||||
|
||||
type ListNotificationPreferencesResponse struct {
|
||||
Preferences []UserNotificationPreference `json:"preferences"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r ListNotificationPreferencesResponse) Error() error { return r.Err }
|
||||
|
||||
type UpdateNotificationPreferencesRequest struct {
|
||||
Preferences []UserNotificationPreference `json:"preferences"`
|
||||
}
|
||||
|
||||
type UpdateNotificationPreferencesResponse struct {
|
||||
Preferences []UserNotificationPreference `json:"preferences"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r UpdateNotificationPreferencesResponse) Error() error { return r.Err }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const (
|
|||
CronAppleMDMIPhoneIPadRefetcher CronScheduleName = "apple_mdm_iphone_ipad_refetcher"
|
||||
CronAppleMDMAPNsPusher CronScheduleName = "apple_mdm_apns_pusher"
|
||||
CronCalendar CronScheduleName = "calendar"
|
||||
CronNotificationDeliveries CronScheduleName = "notification_deliveries"
|
||||
CronUninstallSoftwareMigration CronScheduleName = "uninstall_software_migration"
|
||||
CronUpgradeCodeSoftwareMigration CronScheduleName = "upgrade_code_software_migration"
|
||||
CronMaintainedApps CronScheduleName = "maintained_apps"
|
||||
|
|
|
|||
|
|
@ -147,6 +147,37 @@ type Datastore interface {
|
|||
// only active (non-dismissed, non-resolved) notifications.
|
||||
CountActiveNotificationsForUser(ctx context.Context, userID uint) (unread int, active int, err error)
|
||||
|
||||
// ListUserNotificationPreferences returns the set of (category, channel)
|
||||
// opt-out rows for the given user. Absence of a row means "enabled". The
|
||||
// list is not filled out against AllNotificationCategories here — callers
|
||||
// that need a complete grid synthesize the defaults.
|
||||
ListUserNotificationPreferences(ctx context.Context, userID uint) ([]UserNotificationPreference, error)
|
||||
|
||||
// UpsertUserNotificationPreferences writes the given preferences for the
|
||||
// user in a single transaction. Rows whose Enabled is true are deleted (the
|
||||
// default is enabled, so an explicit row would just add clutter); rows
|
||||
// whose Enabled is false are inserted or updated.
|
||||
UpsertUserNotificationPreferences(ctx context.Context, userID uint, prefs []UserNotificationPreference) error
|
||||
|
||||
// EnqueueNotificationDelivery inserts a pending delivery row for the given
|
||||
// (notification, channel, target). Safe to call repeatedly — the unique
|
||||
// index on (notification_id, channel, target) makes the insert a no-op if
|
||||
// a row already exists, which is what lets the fanout run every producer
|
||||
// tick without double-sending.
|
||||
EnqueueNotificationDelivery(ctx context.Context, notificationID uint, channel NotificationChannel, target string) error
|
||||
|
||||
// ClaimPendingDeliveries returns up to `limit` pending delivery rows for
|
||||
// the given channel along with the corresponding notification payload the
|
||||
// worker needs to send. Each claim marks rows as "sending" internally so
|
||||
// parallel workers don't grab the same row; the caller must subsequently
|
||||
// mark each row via MarkDeliveryResult.
|
||||
ClaimPendingDeliveries(ctx context.Context, channel NotificationChannel, limit int) ([]*NotificationDelivery, map[uint]*Notification, error)
|
||||
|
||||
// MarkDeliveryResult writes the result of a send attempt — either sent
|
||||
// (err == nil) or failed (err != nil, message preserved). Idempotent:
|
||||
// calling again with the same status is harmless.
|
||||
MarkDeliveryResult(ctx context.Context, deliveryID uint, status NotificationDeliveryStatus, errMsg string) error
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// QueryStore
|
||||
|
||||
|
|
|
|||
|
|
@ -481,6 +481,68 @@ type Integrations struct {
|
|||
GoogleCalendar []*GoogleCalendarIntegration `json:"google_calendar"`
|
||||
// ConditionalAccessEnabled indicates whether conditional access is enabled/disabled for "No team".
|
||||
ConditionalAccessEnabled optjson.Bool `json:"conditional_access_enabled"`
|
||||
// SlackNotifications routes in-app notifications to Slack channels by
|
||||
// category. Unlike the per-user in-app prefs, these are admin-configured
|
||||
// and broadcast — everyone subscribed to the channel sees the alert.
|
||||
SlackNotifications SlackNotificationsConfig `json:"slack_notifications"`
|
||||
}
|
||||
|
||||
// SlackNotificationsConfig holds one entry per (category, webhook) route an
|
||||
// admin wants Fleet to deliver to. Multiple routes for the same category are
|
||||
// supported (e.g. a noisy "policies" category could mirror to a firehose
|
||||
// channel and a filtered exec-only channel).
|
||||
type SlackNotificationsConfig struct {
|
||||
Routes []SlackNotificationRoute `json:"routes"`
|
||||
}
|
||||
|
||||
// SlackNotificationRoute is one (category → webhook URL) mapping. The URL
|
||||
// must be an https://hooks.slack.com/ incoming-webhook URL; we don't try to
|
||||
// federate across providers in v1.
|
||||
type SlackNotificationRoute struct {
|
||||
Category NotificationCategory `json:"category"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
}
|
||||
|
||||
// ValidateSlackNotificationsConfig validates the Slack notification routes
|
||||
// admins submitted via ModifyAppConfig. Rules:
|
||||
// - Every route's category must be in AllNotificationCategories.
|
||||
// - Every route's webhook URL must look like a Slack incoming webhook
|
||||
// (https://hooks.slack.com/...) — this catches typos and blocks arbitrary
|
||||
// URLs from being persisted before the cron worker ever tries them.
|
||||
// - Exact duplicates (same category + same URL) are rejected so the admin
|
||||
// doesn't accidentally double-post to the same channel.
|
||||
//
|
||||
// Returns a joined error describing every problem so the admin sees the whole
|
||||
// list in a single round-trip.
|
||||
func ValidateSlackNotificationsConfig(cfg SlackNotificationsConfig) error {
|
||||
valid := make(map[NotificationCategory]struct{}, len(AllNotificationCategories)+1)
|
||||
for _, c := range AllNotificationCategories {
|
||||
valid[c] = struct{}{}
|
||||
}
|
||||
// "all" is a route-only wildcard; accept it here even though it is not in
|
||||
// AllNotificationCategories (which lists the concrete categories the UI
|
||||
// renders in the per-user preferences grid).
|
||||
valid[NotificationCategoryAll] = struct{}{}
|
||||
var errs []string
|
||||
seen := make(map[string]struct{}, len(cfg.Routes))
|
||||
for i, r := range cfg.Routes {
|
||||
if _, ok := valid[r.Category]; !ok {
|
||||
errs = append(errs, fmt.Sprintf("route %d: unknown category %q", i, r.Category))
|
||||
}
|
||||
u := strings.TrimSpace(r.WebhookURL)
|
||||
if u == "" || !strings.HasPrefix(u, "https://hooks.slack.com/") {
|
||||
errs = append(errs, fmt.Sprintf("route %d: webhook_url must start with https://hooks.slack.com/", i))
|
||||
}
|
||||
key := string(r.Category) + "|" + u
|
||||
if _, dup := seen[key]; dup {
|
||||
errs = append(errs, fmt.Sprintf("route %d: duplicate (category, webhook_url)", i))
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.New("invalid slack_notifications: " + strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConditionalAccessIntegration validates "Conditional access" can be enabled on a team/"No team".
|
||||
|
|
|
|||
|
|
@ -67,6 +67,137 @@ const (
|
|||
NotificationAudienceAdmin NotificationAudience = "admin"
|
||||
)
|
||||
|
||||
// NotificationCategory groups related NotificationTypes so users can opt in or
|
||||
// out of a whole class of notifications without us listing every type. A user
|
||||
// who doesn't own vulnerabilities can mute the "vulnerabilities" category and
|
||||
// still receive MDM / license notifications.
|
||||
//
|
||||
// The mapping from type → category lives in NotificationTypeCategory. Adding a
|
||||
// new NotificationType requires adding it to that map so it is routed to the
|
||||
// correct category and respects user preferences.
|
||||
type NotificationCategory string
|
||||
|
||||
const (
|
||||
NotificationCategoryMDM NotificationCategory = "mdm"
|
||||
NotificationCategoryLicense NotificationCategory = "license"
|
||||
NotificationCategoryVulnerabilities NotificationCategory = "vulnerabilities"
|
||||
NotificationCategoryPolicies NotificationCategory = "policies"
|
||||
NotificationCategorySoftware NotificationCategory = "software"
|
||||
NotificationCategoryHosts NotificationCategory = "hosts"
|
||||
NotificationCategoryIntegrations NotificationCategory = "integrations"
|
||||
NotificationCategorySystem NotificationCategory = "system"
|
||||
// NotificationCategoryAll is a sentinel that only makes sense on delivery
|
||||
// routes (Slack webhook configs, etc.) — it matches every real category.
|
||||
// It never appears on a notification row; CategoryForType never returns it.
|
||||
NotificationCategoryAll NotificationCategory = "all"
|
||||
)
|
||||
|
||||
// AllNotificationCategories is the canonical list used by the prefs API and
|
||||
// the My Account UI. The order is the order the categories are rendered.
|
||||
var AllNotificationCategories = []NotificationCategory{
|
||||
NotificationCategoryMDM,
|
||||
NotificationCategoryLicense,
|
||||
NotificationCategoryVulnerabilities,
|
||||
NotificationCategoryPolicies,
|
||||
NotificationCategorySoftware,
|
||||
NotificationCategoryHosts,
|
||||
NotificationCategoryIntegrations,
|
||||
NotificationCategorySystem,
|
||||
}
|
||||
|
||||
// NotificationTypeCategory maps every known NotificationType to the category
|
||||
// it belongs to. Unknown types fall back to NotificationCategorySystem — this
|
||||
// keeps an unregistered demo or partner type visible rather than silently
|
||||
// dropping it, at the cost of classifying it generically.
|
||||
var NotificationTypeCategory = map[NotificationType]NotificationCategory{
|
||||
NotificationTypeAPNsCertExpiring: NotificationCategoryMDM,
|
||||
NotificationTypeAPNsCertExpired: NotificationCategoryMDM,
|
||||
NotificationTypeABMTokenExpiring: NotificationCategoryMDM,
|
||||
NotificationTypeABMTokenExpired: NotificationCategoryMDM,
|
||||
NotificationTypeABMTermsExpired: NotificationCategoryMDM,
|
||||
NotificationTypeVPPTokenExpiring: NotificationCategoryMDM,
|
||||
NotificationTypeVPPTokenExpired: NotificationCategoryMDM,
|
||||
NotificationTypeAndroidEnterpriseDeleted: NotificationCategoryMDM,
|
||||
NotificationTypeLicenseExpiring: NotificationCategoryLicense,
|
||||
NotificationTypeLicenseExpired: NotificationCategoryLicense,
|
||||
|
||||
// Demo notification types — map here so the category-based preference
|
||||
// filter works for them in dev and demo environments.
|
||||
"demo_hosts_offline": NotificationCategoryHosts,
|
||||
"demo_policy_failures": NotificationCategoryPolicies,
|
||||
"demo_vuln_cisa_kev": NotificationCategoryVulnerabilities,
|
||||
"demo_software_failures": NotificationCategorySoftware,
|
||||
"demo_fleet_update": NotificationCategorySystem,
|
||||
"demo_seat_limit": NotificationCategoryLicense,
|
||||
"demo_disk_encryption": NotificationCategoryMDM,
|
||||
"demo_webhook_failing": NotificationCategoryIntegrations,
|
||||
}
|
||||
|
||||
// CategoryForType returns the category for a given NotificationType. Unknown
|
||||
// types land in the "system" bucket; see NotificationTypeCategory for detail.
|
||||
func CategoryForType(t NotificationType) NotificationCategory {
|
||||
if c, ok := NotificationTypeCategory[t]; ok {
|
||||
return c
|
||||
}
|
||||
return NotificationCategorySystem
|
||||
}
|
||||
|
||||
// NotificationChannel identifies a delivery channel for a notification. Only
|
||||
// in_app is read by Fleet today; the column exists so preferences for email
|
||||
// and slack can land ahead of the actual delivery pipeline.
|
||||
type NotificationChannel string
|
||||
|
||||
const (
|
||||
NotificationChannelInApp NotificationChannel = "in_app"
|
||||
NotificationChannelEmail NotificationChannel = "email"
|
||||
NotificationChannelSlack NotificationChannel = "slack"
|
||||
)
|
||||
|
||||
// AllNotificationChannels is the canonical order the UI renders the channel
|
||||
// toggles. Kept narrow for now — the prefs API only surfaces in_app until the
|
||||
// delivery workers for email/slack land.
|
||||
var AllNotificationChannels = []NotificationChannel{
|
||||
NotificationChannelInApp,
|
||||
NotificationChannelEmail,
|
||||
NotificationChannelSlack,
|
||||
}
|
||||
|
||||
// UserNotificationPreference is one row of per-user opt-in state. Rows exist
|
||||
// only for (user, category, channel) combinations the user has explicitly
|
||||
// toggled away from the default. Absence of a row means "enabled" — users
|
||||
// receive new notifications by default.
|
||||
type UserNotificationPreference struct {
|
||||
UserID uint `json:"-" db:"user_id"`
|
||||
Category NotificationCategory `json:"category" db:"category"`
|
||||
Channel NotificationChannel `json:"channel" db:"channel"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
}
|
||||
|
||||
// NotificationDeliveryStatus tracks the lifecycle of a single fanout row in
|
||||
// notification_deliveries. The cron worker transitions pending → sent|failed.
|
||||
type NotificationDeliveryStatus string
|
||||
|
||||
const (
|
||||
NotificationDeliveryStatusPending NotificationDeliveryStatus = "pending"
|
||||
NotificationDeliveryStatusSent NotificationDeliveryStatus = "sent"
|
||||
NotificationDeliveryStatusFailed NotificationDeliveryStatus = "failed"
|
||||
)
|
||||
|
||||
// NotificationDelivery is one row in the notification_deliveries table —
|
||||
// the scheduled (or completed) fanout of a notification to a single
|
||||
// destination (e.g. a Slack webhook URL).
|
||||
type NotificationDelivery struct {
|
||||
ID uint `db:"id"`
|
||||
NotificationID uint `db:"notification_id"`
|
||||
Channel NotificationChannel `db:"channel"`
|
||||
Target string `db:"target"`
|
||||
Status NotificationDeliveryStatus `db:"status"`
|
||||
Error *string `db:"error"`
|
||||
AttemptedAt *time.Time `db:"attempted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
// Notification is a single system-generated event an admin may want to see or
|
||||
// act on. It is the canonical row in the notifications table.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1531,6 +1531,16 @@ type Service interface {
|
|||
// CreateDemoNotification creates a random test notification.
|
||||
// Admin-only, intended for demo/debug purposes only.
|
||||
CreateDemoNotification(ctx context.Context) (*Notification, error)
|
||||
|
||||
// ListNotificationPreferences returns the complete preference grid for
|
||||
// the current user — every category × every channel, filled in with
|
||||
// defaults where the user has not explicitly opted out.
|
||||
ListNotificationPreferences(ctx context.Context) ([]UserNotificationPreference, error)
|
||||
|
||||
// UpdateNotificationPreferences replaces the current user's preferences
|
||||
// with the provided grid. The service validates categories/channels
|
||||
// against the registered enums.
|
||||
UpdateNotificationPreferences(ctx context.Context, prefs []UserNotificationPreference) ([]UserNotificationPreference, error)
|
||||
}
|
||||
|
||||
type KeyValueStore interface {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,16 @@ type MarkAllNotificationsReadFunc func(ctx context.Context, userID uint) error
|
|||
|
||||
type CountActiveNotificationsForUserFunc func(ctx context.Context, userID uint) (unread int, active int, err error)
|
||||
|
||||
type ListUserNotificationPreferencesFunc func(ctx context.Context, userID uint) ([]fleet.UserNotificationPreference, error)
|
||||
|
||||
type UpsertUserNotificationPreferencesFunc func(ctx context.Context, userID uint, prefs []fleet.UserNotificationPreference) error
|
||||
|
||||
type EnqueueNotificationDeliveryFunc func(ctx context.Context, notificationID uint, channel fleet.NotificationChannel, target string) error
|
||||
|
||||
type ClaimPendingDeliveriesFunc func(ctx context.Context, channel fleet.NotificationChannel, limit int) ([]*fleet.NotificationDelivery, map[uint]*fleet.Notification, error)
|
||||
|
||||
type MarkDeliveryResultFunc func(ctx context.Context, deliveryID uint, status fleet.NotificationDeliveryStatus, errMsg string) error
|
||||
|
||||
type ApplyQueriesFunc func(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}) error
|
||||
|
||||
type NewQueryFunc func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error)
|
||||
|
|
@ -2027,6 +2037,21 @@ type DataStore struct {
|
|||
CountActiveNotificationsForUserFunc CountActiveNotificationsForUserFunc
|
||||
CountActiveNotificationsForUserFuncInvoked bool
|
||||
|
||||
ListUserNotificationPreferencesFunc ListUserNotificationPreferencesFunc
|
||||
ListUserNotificationPreferencesFuncInvoked bool
|
||||
|
||||
UpsertUserNotificationPreferencesFunc UpsertUserNotificationPreferencesFunc
|
||||
UpsertUserNotificationPreferencesFuncInvoked bool
|
||||
|
||||
EnqueueNotificationDeliveryFunc EnqueueNotificationDeliveryFunc
|
||||
EnqueueNotificationDeliveryFuncInvoked bool
|
||||
|
||||
ClaimPendingDeliveriesFunc ClaimPendingDeliveriesFunc
|
||||
ClaimPendingDeliveriesFuncInvoked bool
|
||||
|
||||
MarkDeliveryResultFunc MarkDeliveryResultFunc
|
||||
MarkDeliveryResultFuncInvoked bool
|
||||
|
||||
ApplyQueriesFunc ApplyQueriesFunc
|
||||
ApplyQueriesFuncInvoked bool
|
||||
|
||||
|
|
@ -5013,6 +5038,41 @@ func (s *DataStore) CountActiveNotificationsForUser(ctx context.Context, userID
|
|||
return s.CountActiveNotificationsForUserFunc(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListUserNotificationPreferences(ctx context.Context, userID uint) ([]fleet.UserNotificationPreference, error) {
|
||||
s.mu.Lock()
|
||||
s.ListUserNotificationPreferencesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListUserNotificationPreferencesFunc(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *DataStore) UpsertUserNotificationPreferences(ctx context.Context, userID uint, prefs []fleet.UserNotificationPreference) error {
|
||||
s.mu.Lock()
|
||||
s.UpsertUserNotificationPreferencesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.UpsertUserNotificationPreferencesFunc(ctx, userID, prefs)
|
||||
}
|
||||
|
||||
func (s *DataStore) EnqueueNotificationDelivery(ctx context.Context, notificationID uint, channel fleet.NotificationChannel, target string) error {
|
||||
s.mu.Lock()
|
||||
s.EnqueueNotificationDeliveryFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.EnqueueNotificationDeliveryFunc(ctx, notificationID, channel, target)
|
||||
}
|
||||
|
||||
func (s *DataStore) ClaimPendingDeliveries(ctx context.Context, channel fleet.NotificationChannel, limit int) ([]*fleet.NotificationDelivery, map[uint]*fleet.Notification, error) {
|
||||
s.mu.Lock()
|
||||
s.ClaimPendingDeliveriesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ClaimPendingDeliveriesFunc(ctx, channel, limit)
|
||||
}
|
||||
|
||||
func (s *DataStore) MarkDeliveryResult(ctx context.Context, deliveryID uint, status fleet.NotificationDeliveryStatus, errMsg string) error {
|
||||
s.mu.Lock()
|
||||
s.MarkDeliveryResultFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.MarkDeliveryResultFunc(ctx, deliveryID, status, errMsg)
|
||||
}
|
||||
|
||||
func (s *DataStore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}) error {
|
||||
s.mu.Lock()
|
||||
s.ApplyQueriesFuncInvoked = true
|
||||
|
|
@ -7050,7 +7110,7 @@ func (s *DataStore) ApplyHostPetHappinessDelta(ctx context.Context, hostID uint,
|
|||
return s.ApplyHostPetHappinessDeltaFunc(ctx, hostID, delta)
|
||||
}
|
||||
|
||||
func (s *DataStore) CountOpenHostVulnsBySeverity(ctx context.Context, hostID uint) (uint, uint, error) {
|
||||
func (s *DataStore) CountOpenHostVulnsBySeverity(ctx context.Context, hostID uint) (critical uint, high uint, err error) {
|
||||
s.mu.Lock()
|
||||
s.CountOpenHostVulnsBySeverityFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -932,6 +932,10 @@ type MarkAllNotificationsReadFunc func(ctx context.Context) error
|
|||
|
||||
type CreateDemoNotificationFunc func(ctx context.Context) (*fleet.Notification, error)
|
||||
|
||||
type ListNotificationPreferencesFunc func(ctx context.Context) ([]fleet.UserNotificationPreference, error)
|
||||
|
||||
type UpdateNotificationPreferencesFunc func(ctx context.Context, prefs []fleet.UserNotificationPreference) ([]fleet.UserNotificationPreference, error)
|
||||
|
||||
type Service struct {
|
||||
EnrollOsqueryFunc EnrollOsqueryFunc
|
||||
EnrollOsqueryFuncInvoked bool
|
||||
|
|
@ -2301,6 +2305,12 @@ type Service struct {
|
|||
CreateDemoNotificationFunc CreateDemoNotificationFunc
|
||||
CreateDemoNotificationFuncInvoked bool
|
||||
|
||||
ListNotificationPreferencesFunc ListNotificationPreferencesFunc
|
||||
ListNotificationPreferencesFuncInvoked bool
|
||||
|
||||
UpdateNotificationPreferencesFunc UpdateNotificationPreferencesFunc
|
||||
UpdateNotificationPreferencesFuncInvoked bool
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
|
|
@ -5495,3 +5505,17 @@ func (s *Service) CreateDemoNotification(ctx context.Context) (*fleet.Notificati
|
|||
s.mu.Unlock()
|
||||
return s.CreateDemoNotificationFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) ListNotificationPreferences(ctx context.Context) ([]fleet.UserNotificationPreference, error) {
|
||||
s.mu.Lock()
|
||||
s.ListNotificationPreferencesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListNotificationPreferencesFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateNotificationPreferences(ctx context.Context, prefs []fleet.UserNotificationPreference) ([]fleet.UserNotificationPreference, error) {
|
||||
s.mu.Lock()
|
||||
s.UpdateNotificationPreferencesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.UpdateNotificationPreferencesFunc(ctx, prefs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -809,6 +809,17 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
|
|||
appConfig.Integrations.GoogleCalendar = oldAppConfig.Integrations.GoogleCalendar
|
||||
}
|
||||
|
||||
// Slack notification routes: if the client supplied the slack_notifications
|
||||
// block, validate and replace; otherwise leave the existing routes alone.
|
||||
if newAppConfig.Integrations.SlackNotifications.Routes != nil {
|
||||
if err := fleet.ValidateSlackNotificationsConfig(newAppConfig.Integrations.SlackNotifications); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("slack_notifications", err.Error()))
|
||||
}
|
||||
appConfig.Integrations.SlackNotifications = newAppConfig.Integrations.SlackNotifications
|
||||
} else {
|
||||
appConfig.Integrations.SlackNotifications = oldAppConfig.Integrations.SlackNotifications
|
||||
}
|
||||
|
||||
gitopsModeEnabled, gitopsRepoURL := appConfig.GitOpsConfig.GitopsModeEnabled, appConfig.GitOpsConfig.RepositoryURL
|
||||
if gitopsModeEnabled {
|
||||
if !lic.IsPremium() {
|
||||
|
|
|
|||
|
|
@ -494,6 +494,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
ue.StartingAtVersion("2022-04").PATCH("/api/_version_/fleet/notifications/{id:[0-9]+}/read", markNotificationReadEndpoint, fleet.MarkNotificationReadRequest{})
|
||||
ue.StartingAtVersion("2022-04").POST("/api/_version_/fleet/notifications/read_all", markAllNotificationsReadEndpoint, nil)
|
||||
ue.StartingAtVersion("2022-04").POST("/api/_version_/fleet/notifications/demo", createDemoNotificationEndpoint, nil)
|
||||
ue.StartingAtVersion("2022-04").GET("/api/_version_/fleet/notifications/preferences", listNotificationPreferencesEndpoint, nil)
|
||||
ue.StartingAtVersion("2022-04").PUT("/api/_version_/fleet/notifications/preferences", updateNotificationPreferencesEndpoint, fleet.UpdateNotificationPreferencesRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_url", getHostDeviceURLEndpoint, getHostDeviceURLRequest{})
|
||||
|
||||
// Pet demo endpoints — registered conditionally based on the `pet_demo`
|
||||
|
|
|
|||
|
|
@ -316,9 +316,118 @@ func (svc *Service) CreateDemoNotification(ctx context.Context) (*fleet.Notifica
|
|||
sample := demoNotifications[rand.IntN(len(demoNotifications))]
|
||||
sample.DedupeKey = fmt.Sprintf("demo_%s_%d", sample.Type, rand.IntN(100000))
|
||||
|
||||
n, err := svc.ds.UpsertNotification(ctx, sample)
|
||||
n, err := svc.upsertNotification(ctx, sample)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "create demo notification")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Per-user notification preferences
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func listNotificationPreferencesEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
prefs, err := svc.ListNotificationPreferences(ctx)
|
||||
if err != nil {
|
||||
return fleet.ListNotificationPreferencesResponse{Err: err}, nil
|
||||
}
|
||||
return fleet.ListNotificationPreferencesResponse{Preferences: prefs}, nil
|
||||
}
|
||||
|
||||
func updateNotificationPreferencesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*fleet.UpdateNotificationPreferencesRequest)
|
||||
prefs, err := svc.UpdateNotificationPreferences(ctx, req.Preferences)
|
||||
if err != nil {
|
||||
return fleet.UpdateNotificationPreferencesResponse{Err: err}, nil
|
||||
}
|
||||
return fleet.UpdateNotificationPreferencesResponse{Preferences: prefs}, nil
|
||||
}
|
||||
|
||||
// fullPreferenceGrid expands a sparse set of opt-out rows into the complete
|
||||
// (category, channel) grid, filling defaults (enabled=true) for combinations
|
||||
// the user hasn't explicitly toggled. This is what the UI wants: a complete
|
||||
// matrix to drive its toggles.
|
||||
func fullPreferenceGrid(userID uint, stored []fleet.UserNotificationPreference) []fleet.UserNotificationPreference {
|
||||
byKey := make(map[string]fleet.UserNotificationPreference, len(stored))
|
||||
for _, p := range stored {
|
||||
byKey[string(p.Category)+"|"+string(p.Channel)] = p
|
||||
}
|
||||
grid := make([]fleet.UserNotificationPreference, 0, len(fleet.AllNotificationCategories)*len(fleet.AllNotificationChannels))
|
||||
for _, c := range fleet.AllNotificationCategories {
|
||||
for _, ch := range fleet.AllNotificationChannels {
|
||||
if p, ok := byKey[string(c)+"|"+string(ch)]; ok {
|
||||
grid = append(grid, p)
|
||||
continue
|
||||
}
|
||||
grid = append(grid, fleet.UserNotificationPreference{
|
||||
UserID: userID,
|
||||
Category: c,
|
||||
Channel: ch,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
func (svc *Service) ListNotificationPreferences(
|
||||
ctx context.Context,
|
||||
) ([]fleet.UserNotificationPreference, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Notification{}, fleet.ActionRead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fleet.ErrNoContext
|
||||
}
|
||||
stored, err := svc.ds.ListUserNotificationPreferences(ctx, vc.UserID())
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "load user notification preferences")
|
||||
}
|
||||
return fullPreferenceGrid(vc.UserID(), stored), nil
|
||||
}
|
||||
|
||||
func (svc *Service) UpdateNotificationPreferences(
|
||||
ctx context.Context, prefs []fleet.UserNotificationPreference,
|
||||
) ([]fleet.UserNotificationPreference, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Notification{}, fleet.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fleet.ErrNoContext
|
||||
}
|
||||
|
||||
// Validate against the known enums. Accumulate all errors so the caller
|
||||
// sees every bad row at once.
|
||||
validCategory := make(map[fleet.NotificationCategory]struct{}, len(fleet.AllNotificationCategories))
|
||||
for _, c := range fleet.AllNotificationCategories {
|
||||
validCategory[c] = struct{}{}
|
||||
}
|
||||
validChannel := make(map[fleet.NotificationChannel]struct{}, len(fleet.AllNotificationChannels))
|
||||
for _, ch := range fleet.AllNotificationChannels {
|
||||
validChannel[ch] = struct{}{}
|
||||
}
|
||||
invalid := fleet.NewInvalidArgumentError("preferences", "")
|
||||
for i, p := range prefs {
|
||||
if _, ok := validCategory[p.Category]; !ok {
|
||||
invalid.Append("preferences", fmt.Sprintf("row %d: unknown category %q", i, p.Category))
|
||||
}
|
||||
if _, ok := validChannel[p.Channel]; !ok {
|
||||
invalid.Append("preferences", fmt.Sprintf("row %d: unknown channel %q", i, p.Channel))
|
||||
}
|
||||
}
|
||||
if invalid.HasErrors() {
|
||||
return nil, invalid
|
||||
}
|
||||
|
||||
if err := svc.ds.UpsertUserNotificationPreferences(ctx, vc.UserID(), prefs); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "upsert user notification preferences")
|
||||
}
|
||||
stored, err := svc.ds.ListUserNotificationPreferences(ctx, vc.UserID())
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "reload user notification preferences")
|
||||
}
|
||||
return fullPreferenceGrid(vc.UserID(), stored), nil
|
||||
}
|
||||
|
|
|
|||
207
server/service/notifications_fanout.go
Normal file
207
server/service/notifications_fanout.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
// fanoutSlackForNotification enqueues one pending notification_deliveries row
|
||||
// per matching slack_notifications route. Called after every successful
|
||||
// UpsertNotification in the service layer.
|
||||
//
|
||||
// The unique index on (notification_id, channel, target) makes this
|
||||
// idempotent: producers that re-upsert the same dedupe key on a cron tick
|
||||
// won't accumulate extra delivery rows. We still call it every time because
|
||||
// a new notification (different dedupe key) needs its own deliveries.
|
||||
func fanoutSlackForNotification(ctx context.Context, ds fleet.Datastore, notif *fleet.Notification) error {
|
||||
if notif == nil {
|
||||
return nil
|
||||
}
|
||||
cfg, err := ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "load app config for slack fanout")
|
||||
}
|
||||
category := fleet.CategoryForType(notif.Type)
|
||||
for _, route := range cfg.Integrations.SlackNotifications.Routes {
|
||||
// "all" is a wildcard route — it matches every category so admins can
|
||||
// mirror every Fleet notification to a firehose channel with a single
|
||||
// row instead of one row per category.
|
||||
if route.Category != fleet.NotificationCategoryAll && route.Category != category {
|
||||
continue
|
||||
}
|
||||
if err := ds.EnqueueNotificationDelivery(ctx, notif.ID, fleet.NotificationChannelSlack, route.WebhookURL); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueue slack delivery")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertNotification is the one canonical write path for notifications in the
|
||||
// service layer: it upserts the row and fans out any admin-configured Slack
|
||||
// routes. Use it instead of calling svc.ds.UpsertNotification directly.
|
||||
//
|
||||
// Fanout errors are logged but not returned — a transient app_config read or
|
||||
// delivery-row insert failure shouldn't reject an otherwise-valid producer
|
||||
// upsert (the next cron tick will retry fanout via the INSERT IGNORE).
|
||||
func (svc *Service) upsertNotification(ctx context.Context, u fleet.NotificationUpsert) (*fleet.Notification, error) {
|
||||
n, err := svc.ds.UpsertNotification(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fanoutSlackForNotification(ctx, svc.ds, n); err != nil {
|
||||
svc.logger.WarnContext(ctx, "slack notification fanout failed",
|
||||
"notification_id", n.ID, "type", n.Type, "err", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PostSlackWebhook POSTs a Block Kit payload for the given notification to a
|
||||
// Slack incoming-webhook URL. Returns the error verbatim so the worker can
|
||||
// persist a meaningful failure reason.
|
||||
//
|
||||
// serverBaseURL is used to absolutize any relative CTA URL on the
|
||||
// notification — Slack's Block Kit button.url field rejects relative paths
|
||||
// with invalid_blocks. Pass "" to disable CTA buttons entirely for
|
||||
// notifications that only carry a relative path.
|
||||
//
|
||||
// Exported so the cron worker in server/cron can inject this as the Slack
|
||||
// sender without creating a direct dependency between packages.
|
||||
func PostSlackWebhook(ctx context.Context, url string, notif *fleet.Notification, serverBaseURL string) error {
|
||||
body, err := json.Marshal(slackPayload(notif, serverBaseURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build slack request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack webhook POST: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
// Slack returns plain-text errors on the webhook endpoint; surface a
|
||||
// truncated version so the delivery row captures context.
|
||||
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("slack webhook returned %d: %s",
|
||||
resp.StatusCode, strings.TrimSpace(string(preview)))
|
||||
}
|
||||
|
||||
// slackPayload renders a notification to Slack's Block Kit format. The layout
|
||||
// is kept deliberately compact so it fits cleanly in a channel without being
|
||||
// noisy: one header (severity-prefixed), one body section, and an optional
|
||||
// action button pointing at the notification's CTA.
|
||||
//
|
||||
// Slack enforces a hard 150-char limit on plain_text.text in header blocks
|
||||
// (invalid_blocks otherwise) and requires button.url to be an absolute
|
||||
// http(s) URL. Both are handled here so producers can keep emitting
|
||||
// Fleet-internal relative paths like "/policies" without Slack failures.
|
||||
func slackPayload(n *fleet.Notification, serverBaseURL string) map[string]interface{} {
|
||||
prefix := severityPrefix(n.Severity)
|
||||
headerText := truncateForSlackHeader(fmt.Sprintf("%s %s", prefix, n.Title))
|
||||
blocks := []map[string]interface{}{
|
||||
{
|
||||
"type": "header",
|
||||
"text": map[string]interface{}{
|
||||
"type": "plain_text",
|
||||
"text": headerText,
|
||||
"emoji": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": map[string]interface{}{
|
||||
"type": "mrkdwn",
|
||||
"text": n.Body,
|
||||
},
|
||||
},
|
||||
}
|
||||
if btnURL := resolveCTAURL(n.CTAURL, serverBaseURL); btnURL != "" {
|
||||
label := "View"
|
||||
if n.CTALabel != nil && *n.CTALabel != "" {
|
||||
label = *n.CTALabel
|
||||
}
|
||||
blocks = append(blocks, map[string]interface{}{
|
||||
"type": "actions",
|
||||
"elements": []map[string]interface{}{
|
||||
{
|
||||
"type": "button",
|
||||
"text": map[string]interface{}{
|
||||
"type": "plain_text",
|
||||
"text": label,
|
||||
"emoji": false,
|
||||
},
|
||||
"url": btnURL,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"text": headerText,
|
||||
"blocks": blocks,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCTAURL returns a Slack-safe absolute URL for the notification's
|
||||
// call-to-action, or "" if we can't build one. Absolute URLs are passed
|
||||
// through unchanged; relative paths are joined to serverBaseURL. If the
|
||||
// CTA is relative and no serverBaseURL is configured, we drop the button
|
||||
// rather than send Slack a value it will reject with invalid_blocks.
|
||||
func resolveCTAURL(cta *string, serverBaseURL string) string {
|
||||
if cta == nil || *cta == "" {
|
||||
return ""
|
||||
}
|
||||
raw := strings.TrimSpace(*cta)
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
return raw
|
||||
}
|
||||
base := strings.TrimRight(serverBaseURL, "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/") {
|
||||
raw = "/" + raw
|
||||
}
|
||||
return base + raw
|
||||
}
|
||||
|
||||
// truncateForSlackHeader enforces Slack's 150-char limit on header
|
||||
// plain_text. Ellipsize mid-word if necessary rather than failing the send.
|
||||
func truncateForSlackHeader(s string) string {
|
||||
const max = 150
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
|
||||
// severityPrefix picks an emoji that makes severity scannable in a busy
|
||||
// channel. Slack already renders these inline — no extra setup required.
|
||||
func severityPrefix(s fleet.NotificationSeverity) string {
|
||||
switch s {
|
||||
case fleet.NotificationSeverityError:
|
||||
return ":rotating_light:"
|
||||
case fleet.NotificationSeverityWarning:
|
||||
return ":warning:"
|
||||
default:
|
||||
return ":information_source:"
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ func (svc *Service) producerLicense(ctx context.Context) error {
|
|||
now := time.Now()
|
||||
switch {
|
||||
case lic.Expiration.Before(now):
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeLicenseExpired,
|
||||
Severity: fleet.NotificationSeverityError,
|
||||
Title: "Premium license expired",
|
||||
|
|
@ -83,7 +83,7 @@ func (svc *Service) producerLicense(ctx context.Context) error {
|
|||
return err
|
||||
case lic.Expiration.Before(now.Add(notificationExpiryWindow)):
|
||||
days := int(time.Until(lic.Expiration).Hours() / 24)
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeLicenseExpiring,
|
||||
Severity: fleet.NotificationSeverityWarning,
|
||||
Title: "Premium license expires soon",
|
||||
|
|
@ -112,7 +112,7 @@ func (svc *Service) producerABMTerms(ctx context.Context) error {
|
|||
return ctxerr.Wrap(ctx, err, "load app config for abm terms")
|
||||
}
|
||||
if appCfg.MDM.AppleBMTermsExpired {
|
||||
_, err := svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err := svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeABMTermsExpired,
|
||||
Severity: fleet.NotificationSeverityError,
|
||||
Title: "Apple Business Manager terms need renewal",
|
||||
|
|
@ -162,7 +162,7 @@ func (svc *Service) producerABMTokens(ctx context.Context) error {
|
|||
|
||||
switch {
|
||||
case earliest.RenewAt.Before(now):
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeABMTokenExpired,
|
||||
Severity: fleet.NotificationSeverityError,
|
||||
Title: "Apple Business Manager token expired",
|
||||
|
|
@ -177,7 +177,7 @@ func (svc *Service) producerABMTokens(ctx context.Context) error {
|
|||
return err
|
||||
case earliest.RenewAt.Before(now.Add(notificationExpiryWindow)):
|
||||
days := int(time.Until(earliest.RenewAt).Hours() / 24)
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeABMTokenExpiring,
|
||||
Severity: fleet.NotificationSeverityWarning,
|
||||
Title: "Apple Business Manager token expiring soon",
|
||||
|
|
@ -228,7 +228,7 @@ func (svc *Service) producerVPPTokens(ctx context.Context) error {
|
|||
|
||||
switch {
|
||||
case earliest.RenewDate.Before(now):
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeVPPTokenExpired,
|
||||
Severity: fleet.NotificationSeverityError,
|
||||
Title: "VPP token expired",
|
||||
|
|
@ -243,7 +243,7 @@ func (svc *Service) producerVPPTokens(ctx context.Context) error {
|
|||
return err
|
||||
case earliest.RenewDate.Before(now.Add(notificationExpiryWindow)):
|
||||
days := int(time.Until(earliest.RenewDate).Hours() / 24)
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeVPPTokenExpiring,
|
||||
Severity: fleet.NotificationSeverityWarning,
|
||||
Title: "VPP token expiring soon",
|
||||
|
|
@ -291,7 +291,7 @@ func (svc *Service) producerAPNsCert(ctx context.Context) error {
|
|||
|
||||
switch {
|
||||
case cert.NotAfter.Before(now):
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeAPNsCertExpired,
|
||||
Severity: fleet.NotificationSeverityError,
|
||||
Title: "APNs certificate expired",
|
||||
|
|
@ -306,7 +306,7 @@ func (svc *Service) producerAPNsCert(ctx context.Context) error {
|
|||
return err
|
||||
case cert.NotAfter.Before(now.Add(notificationExpiryWindow)):
|
||||
days := int(time.Until(cert.NotAfter).Hours() / 24)
|
||||
_, err = svc.ds.UpsertNotification(ctx, fleet.NotificationUpsert{
|
||||
_, err = svc.upsertNotification(ctx, fleet.NotificationUpsert{
|
||||
Type: fleet.NotificationTypeAPNsCertExpiring,
|
||||
Severity: fleet.NotificationSeverityWarning,
|
||||
Title: "APNs certificate expiring soon",
|
||||
|
|
|
|||
Loading…
Reference in a new issue