Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 7 additions & 19 deletions apps/webapp/app/services/directorySyncEffects.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,15 @@ import { createPlatformNotification } from "~/services/platformNotifications.ser

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.
// 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 });
}

// 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).
// 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: {
Expand Down Expand Up @@ -72,9 +66,6 @@ async function notifyLastOwnerProtected(userId: string, organizationId: string):
}
}

// 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": {
Expand All @@ -95,8 +86,8 @@ async function applyEffect(effect: DirectorySyncEffect): Promise<void> {
source: "directory_sync",
});

// Directory is authoritative for role: overwrite even for an existing
// member (ensureOrgMember only sets the role on first create).
// Directory owns the role: overwrite even an existing member
// (ensureOrgMember only sets it on create).
if (effect.roleId) {
const result = await rbac.setUserRole({
userId,
Expand Down Expand Up @@ -133,9 +124,6 @@ async function applyEffect(effect: DirectorySyncEffect): Promise<void> {
}
}

// 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);
Expand Down