Adding notification settings and slack integration

This commit is contained in:
George Karr 2026-04-16 12:50:02 -05:00
parent b1355ae3f8
commit e58f948e58
37 changed files with 1783 additions and 35 deletions

View file

@ -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)

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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 {

View 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.",
},
};

View file

@ -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()}

View file

@ -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;

View 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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./NotificationSettings";

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./SlackNotifications";

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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`,

View file

@ -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,
});
},
};

View file

@ -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`,

View 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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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(&notifID))
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)
}

View file

@ -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), &notifs, 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
}

View file

@ -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 }

View file

@ -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"

View file

@ -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

View file

@ -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".

View file

@ -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.
//

View file

@ -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 {

View file

@ -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()

View file

@ -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)
}

View file

@ -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() {

View file

@ -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`

View file

@ -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
}

View 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:"
}
}

View file

@ -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",