Files
triggerdotdev--trigger.dev/apps/webapp/app/services/directorySyncEffects.server.ts
Oskar Otwinowski e57fd9ce90 fix(webapp): downgrade retryable directory-sync effect failures to warn (#4200)
Directory-sync effects are idempotent and the accounts-webhook worker
retries
the whole event, so a single failed attempt (typically a role assignment
losing a serializable race during a backfill burst) is self-healing
rather
than alert-worthy. Tag those thrown errors with logLevel "warn" so the
worker
logs at warn instead of error, keeping them visible for triage without
paging.
2026-07-09 17:57:40 +02:00

144 lines
5.1 KiB
TypeScript

import type { DirectorySyncEffect } from "@trigger.dev/plugins";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";
import {
ensureOrgMember,
ensureUserForDirectory,
removeOrgMemberForDirectory,
} from "~/models/orgMember.server";
import { createPlatformNotification } from "~/services/platformNotifications.server";
const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected";
// Directory-sync effects are idempotent and the accounts-webhook worker retries
// the whole event (maxAttempts: 5). A single failed attempt is therefore an
// expected, self-healing condition rather than an alert-worthy error — the most
// common case is a role assignment losing a serializable race during a backfill
// burst (the plugin already retries that internally; the worker retry mops up
// the rest). Tag the thrown error with `logLevel: "warn"` so the worker logs it
// at warn instead of error (see redis-worker `processItem`), keeping it visible
// for triage without paging as an error on every transient retry.
function retryableEffectError(message: string): Error {
return Object.assign(new Error(message), { logLevel: "warn" as const });
}
// Raise a user-scoped, deduped notification when the directory tried to
// remove the org's last Owner. We keep the member and tell the Owner what to
// do; a single undismissed notification is enough (don't spam on every retry).
async function notifyLastOwnerProtected(userId: string, organizationId: string): Promise<void> {
const existing = await prisma.platformNotification.findFirst({
where: {
scope: "USER",
userId,
surface: "WEBAPP",
title: LAST_OWNER_NOTIFICATION_TITLE,
archivedAt: null,
endsAt: { gt: new Date() },
},
select: { id: true, interactions: { where: { userId }, select: { webappDismissedAt: true } } },
});
if (existing && !existing.interactions[0]?.webappDismissedAt) {
return;
}
const endsAt = new Date();
endsAt.setFullYear(endsAt.getFullYear() + 1);
const result = await createPlatformNotification({
title: LAST_OWNER_NOTIFICATION_TITLE,
surface: "WEBAPP",
scope: "USER",
userId,
endsAt: endsAt.toISOString(),
priority: 10,
payload: {
version: "1",
data: {
type: "card",
title: "Directory Sync kept your Owner access",
description:
"Your identity provider tried to remove you from this organization, but you are its only Owner. " +
"We kept your membership to prevent a lockout. Assign another Owner, then the directory change will apply.",
},
},
});
if (result.isErr()) {
logger.warn("directorySync: failed to create last-owner notification", {
userId,
organizationId,
error: result.error,
});
}
}
// Apply one directory-sync membership effect against public.* tables. The
// plugin owns all enterprise.* state and never writes here; this is the only
// path that mutates User / OrgMember / roles / tokens from a directory event.
async function applyEffect(effect: DirectorySyncEffect): Promise<void> {
switch (effect.kind) {
case "provision": {
const userId =
effect.userId ??
(
await ensureUserForDirectory({
email: effect.email,
firstName: effect.firstName,
lastName: effect.lastName,
})
).userId;
await ensureOrgMember({
userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
source: "directory_sync",
});
// Directory is authoritative for role: overwrite even for an existing
// member (ensureOrgMember only sets the role on first create).
if (effect.roleId) {
const result = await rbac.setUserRole({
userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
});
if (!result.ok) {
throw retryableEffectError(`directorySync provision setUserRole failed: ${result.error}`);
}
}
return;
}
case "set_role": {
const result = await rbac.setUserRole({
userId: effect.userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
});
if (!result.ok) {
throw retryableEffectError(`directorySync set_role failed: ${result.error}`);
}
return;
}
case "deprovision": {
const outcome = await removeOrgMemberForDirectory({
userId: effect.userId,
organizationId: effect.organizationId,
});
if (!outcome.removed && outcome.reason === "last_owner_protected") {
await notifyLastOwnerProtected(effect.userId, effect.organizationId);
}
return;
}
}
}
// Apply all effects from a processed directory-sync webhook. Effects are
// idempotent, so a worker retry that re-applies them converges. A throw
// propagates to the worker for retry.
export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise<void> {
for (const effect of effects) {
await applyEffect(effect);
}
}