chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user