sh-mcp/auth/group-sync/test/sync.test.ts

81 lines
2.8 KiB
TypeScript
Raw Normal View History

Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas (#4) * Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas Stands up the real AWS auth broker the servers already validate against (SH_MCP_ENV=aws), surface-agnostic. Nothing deployed yet (gated on Google secrets); CI synthesizes the stack. infra/ — root CDK app, stack sh-mcp-auth: - Cognito user pool, ESSENTIALS feature plan (required for the V2 pre-token trigger), Google external OIDC IdP (client_id/secret resolved from Secrets Manager at deploy via CFN dynamic reference, never inlined). - Resource servers + per-tier app clients whose AllowedOAuthScopes ARE the trust-tier boundary: ops=(read,tasks), exec=(ops+gmail/calendar, NO finance), finance=(finance:read ONLY, 15-min access TTL). offline refresh 30d. - Cognito groups sh-mcp-ops/-assistant/-finance/-admin. - sync-state + deny-list DynamoDB tables (overrideLogicalId pinned so a future refactor cannot replace+drop them; deny-list TTL attr 'expiresAt'). - Least-priv IAM (no wildcard action/resource; Google SA secret grant scoped to the one secret), arm64 Lambdas, explicit 60-day log groups, alarms on the seahaven-alarm-topics CMK (ALARM-state actions only, two-alarm group-sync). auth/pre-token-gen — SUPPRESS-ONLY V2 Lambda. Maps Cognito group entitlement to scopesToSuppress; NEVER scopesToAdd a tier scope (AllowedOAuthScopes stays the ceiling). Reads last_successful_sync; fail-closed to base ops:read when stale. auth/group-sync — mirrors Google Group membership into Cognito groups every 5 min (jose-signed SA JWT -> Directory API, no googleapis dep); writes the freshness marker ONLY on full success so a partial failure keeps the pre-token Lambda failing closed. 37 new tests (suppress-only policy, fail-closed, reconcile diff, 16 CDK assertions incl. Essentials/V2/per-client-scope/no-wildcard-IAM). 448 total pass; tsc -b + infra typecheck + cdk synth + prettier clean; CI run-cdk-synth re-enabled. App-client callback URLs are a context placeholder pending the surface decision. Confluence map (1540098) + project memory updates owed once this deploys. * Phase 2a: harden auth substrate per security-review + IAM cross-review Both mandatory gates run on the 2a diff. GPT-4.1 IAM/Lambda cross-review: the suppress-only invariant is now an executable fail-closed guard (a future edit that sets scopesToAdd throws → no token minted). /sh-security-review fan-out + proof-or-kill verifier: PASS (0 confirmed critical/high). The verifier refuted the two "high" candidates (the email-case revocation "bypass" is symmetric — the add path uses the same lowercasing filter, so an un-removable user could never have been added; the empty-directory purge is a non-200 throw → stale marker → fail closed). Three confirmed findings remediated: - C2 (deny-list was inert): the sh-mcp-deny-list table was provisioned and documented as "hard revocation" but no code read it. The pre-token Lambda now reads it on every mint (DENY_LIST_TABLE env + grantReadData) and strips a deny-listed sub to NO tier scopes, ahead of the next group sync. Fail-OPEN on a DDB read error (logs deny_list_read_failed) so a blip can't lock everyone out — group membership + its fail-closed 30-min window stay authoritative. - C5 (finance 30-day refresh nullified the 15-min access TTL): refresh window is now per-tier; finance caps at 8h, ops/exec keep 30d. - C7 (nested Google-group members silently dropped): listGroupMembers now sets includeDerivedMembership and skips non-USER rows, honoring the documented "nested resolved" contract instead of pushing a phantom group address. Also corrects the sync.ts comment that overstated fail-closed as instantaneous (it is bounded by MAX_SYNC_AGE_MS). +8 tests (deny-list unit, hard-revocation handler path, finance refresh window, deny-list env wiring); 456 pass. tsc -b, cdk synth, prettier, eslint all clean.
2026-06-26 14:33:46 -04:00
import { describe, it, expect, vi } from 'vitest';
import { runSync, type SyncDeps } from '../src/sync.js';
import type { DirectoryReader, CognitoGroupTarget, SyncStateWriter } from '../src/clients.js';
function fakes(
directoryMembers: Record<string, string[]>,
cognitoMembers: Record<string, string[]>,
) {
const added: string[] = [];
const removed: string[] = [];
const writes: number[] = [];
const directory: DirectoryReader = {
listGroupMembers: vi.fn(async (email: string) => directoryMembers[email] ?? []),
};
const cognito: CognitoGroupTarget = {
ensureGroup: vi.fn(async () => {}),
listMembers: vi.fn(async (g: string) => cognitoMembers[g] ?? []),
addMember: vi.fn(async (g: string, e: string) => {
added.push(`${g}:${e}`);
}),
removeMember: vi.fn(async (g: string, e: string) => {
removed.push(`${g}:${e}`);
}),
};
const syncState: SyncStateWriter = {
writeLastSuccessfulSync: vi.fn(async (ms: number) => {
writes.push(ms);
}),
};
const deps: SyncDeps = {
directory,
cognito,
syncState,
domain: 'seahavenind.com',
now: () => 1000,
};
return { deps, added, removed, writes, directory, cognito };
}
describe('runSync', () => {
it('reconciles each group and writes the freshness marker once, on full success', async () => {
const { deps, added, removed, writes } = fakes(
{ 'sh-mcp-finance@seahavenind.com': ['adam@seahavenind.com', 'new@seahavenind.com'] },
{ 'sh-mcp-finance': ['adam@seahavenind.com', 'gone@seahavenind.com'] },
);
await runSync(deps);
expect(added).toContain('sh-mcp-finance:new@seahavenind.com');
expect(removed).toContain('sh-mcp-finance:gone@seahavenind.com');
expect(writes).toEqual([1000]); // marker written exactly once
});
it('ensures every managed group exists', async () => {
const { deps, cognito } = fakes({}, {});
await runSync(deps);
expect(cognito.ensureGroup).toHaveBeenCalledTimes(4);
});
it('FAIL CLOSED: a Directory error aborts and the marker is NOT written', async () => {
const { deps, writes, directory } = fakes({}, {});
(directory.listGroupMembers as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error('directory 503'),
);
await expect(runSync(deps)).rejects.toThrow('directory 503');
expect(writes).toEqual([]); // stale marker preserved → pre-token fails closed
});
it('FAIL CLOSED: a Cognito reconcile error aborts before the marker write', async () => {
const { deps, writes, cognito } = fakes(
{ 'sh-mcp-ops@seahavenind.com': ['x@seahavenind.com'] },
{},
);
(cognito.addMember as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error('cognito throttled'),
);
await expect(runSync(deps)).rejects.toThrow('cognito throttled');
expect(writes).toEqual([]);
});
});