/** * The pure reconcile orchestration, decoupled from AWS/Google SDKs via the * {@link clients} interfaces so it is fully unit-testable. * * Invariant: the freshness marker is written ONLY after EVERY managed group * reconciles without error. A partial failure leaves the marker at its prior * value, so the pre-token Lambda fails closed once that value ages past * MAX_SYNC_AGE_MS (the bounded revocation-latency window — not instantaneous; * a partial failure is no worse than a fully missed run). Adds run before * removes within a group, so the worst case inside that window is an * over-grant that the window then collapses (design.md §2.3; agentforce-plan * §0.1 CR-5). */ import type { DirectoryReader, CognitoGroupTarget, SyncStateWriter } from './clients.js'; import { managedGroups, computeMembershipDiff, type ManagedGroup } from './groups.js'; export interface SyncDeps { directory: DirectoryReader; cognito: CognitoGroupTarget; syncState: SyncStateWriter; domain: string; /** Injectable clock for deterministic tests. */ now?: () => number; } export interface GroupSyncResult { group: string; added: number; removed: number; } /** Reconcile a single managed group; returns the applied change counts. */ async function reconcileGroup(deps: SyncDeps, g: ManagedGroup): Promise { await deps.cognito.ensureGroup(g.cognito); const [desired, current] = await Promise.all([ deps.directory.listGroupMembers(g.googleEmail), deps.cognito.listMembers(g.cognito), ]); const { toAdd, toRemove } = computeMembershipDiff(current, desired); // Adds before removes so a membership move never leaves a user with no group. for (const email of toAdd) await deps.cognito.addMember(g.cognito, email); for (const email of toRemove) await deps.cognito.removeMember(g.cognito, email); return { group: g.cognito, added: toAdd.length, removed: toRemove.length }; } /** * Reconcile every managed group, then (only on full success) write the freshness * marker. Throws if any group fails — the caller surfaces that as a Lambda error * so the CloudWatch alarm fires and the marker stays stale. */ export async function runSync(deps: SyncDeps): Promise { const now = deps.now ?? Date.now; const results: GroupSyncResult[] = []; for (const g of managedGroups(deps.domain)) { results.push(await reconcileGroup(deps, g)); } // Reached only if all groups reconciled without throwing. await deps.syncState.writeLastSuccessfulSync(now()); return results; }