Files
triggerdotdev--trigger.dev/apps/webapp/app/services/directorySyncEffects.server.ts
T
2026-07-13 13:32:57 +08:00

153 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";
// Effects are idempotent and the worker retries, so a single failed attempt is
// transient (usually a serializable-conflict retry), not an alert. `logLevel`
// makes the worker log it at warn instead of paging.
function retryableEffectError(message: string): Error {
return Object.assign(new Error(message), { logLevel: "warn" as const });
}
// Deduped notification when the directory tried to remove the org's last Owner:
// we keep the member, and one undismissed notification is enough (no retry spam).
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,
});
}
}
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 owns the role: overwrite even an existing member
// (ensureOrgMember only sets it on create).
if (effect.roleId) {
const result = await rbac.setUserRole({
userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
});
if (!result.ok) {
// The org must keep one Owner: skip the role overwrite for the last
// Owner (they keep Owner) instead of failing the whole batch. Applies
// to a directory burst and to a dashboard group remap alike.
if (result.code === "last_owner") {
logger.info("directorySync: kept last Owner, skipped provision role overwrite", {
userId,
organizationId: effect.organizationId,
});
} else {
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) {
// Keeping the org's last Owner is expected, not a failure — skip this
// one member and let the rest of the remap apply (no server error).
if (result.code === "last_owner") {
logger.info("directorySync: kept last Owner, skipped set_role", {
userId: effect.userId,
organizationId: effect.organizationId,
});
return;
}
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;
}
}
}
export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise<void> {
for (const effect of effects) {
await applyEffect(effect);
}
}