mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-03 10:23:19 +00:00
62 lines
2.5 KiB
TypeScript
62 lines
2.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<GroupSyncResult> {
|
||
|
|
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<GroupSyncResult[]> {
|
||
|
|
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;
|
||
|
|
}
|