sh-mcp/auth/group-sync/src/clients.ts

113 lines
4.7 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
/**
* Injectable client interfaces for the group-sync handler, plus their real
* implementations. Keeping the handler dependent on these interfaces (not the
* concrete SDKs) is what makes the reconcile logic unit-testable without AWS or
* Google in the loop.
*/
/** Reads group membership from the Google Workspace Directory API. */
export interface DirectoryReader {
/**
* User email addresses of a Google Group, with nested groups flattened to their
* underlying users (`includeDerivedMembership`). Non-user (GROUP) rows are excluded.
*/
listGroupMembers(groupEmail: string): Promise<string[]>;
}
/** Reconciles membership of a Cognito group. */
export interface CognitoGroupTarget {
/** Create the group if it does not already exist (idempotent). */
ensureGroup(groupName: string): Promise<void>;
/** Email addresses currently in the Cognito group. */
listMembers(groupName: string): Promise<string[]>;
addMember(groupName: string, email: string): Promise<void>;
removeMember(groupName: string, email: string): Promise<void>;
}
/** Persists the freshness marker the pre-token Lambda fails closed on. */
export interface SyncStateWriter {
writeLastSuccessfulSync(epochMs: number): Promise<void>;
}
// ---------------------------------------------------------------------------
// Real implementations (externalized at bundle time; exercised only in aws mode)
// ---------------------------------------------------------------------------
import { SignJWT, importPKCS8 } from 'jose';
/** Google service-account credentials (from Secrets Manager) for domain-wide delegation. */
export interface GoogleServiceAccount {
client_email: string;
private_key: string;
/** The admin user to impersonate for Directory reads (domain-wide delegation subject). */
subject: string;
}
/**
* Directory reader backed by a service account with domain-wide delegation.
* Mints a short-lived bearer token via the OAuth2 JWT grant (signed with `jose`,
* no googleapis dependency) and pages the Admin SDK members endpoint.
*/
export class GoogleDirectoryReader implements DirectoryReader {
constructor(private readonly sa: GoogleServiceAccount) {}
private async accessToken(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const key = await importPKCS8(this.sa.private_key, 'RS256');
const assertion = await new SignJWT({
scope: 'https://www.googleapis.com/auth/admin.directory.group.readonly',
})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(this.sa.client_email)
.setSubject(this.sa.subject)
.setAudience('https://oauth2.googleapis.com/token')
.setIssuedAt(now)
.setExpirationTime(now + 3600)
.sign(key);
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!res.ok) throw new Error(`Google token exchange failed: ${res.status}`);
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error('Google token exchange returned no access_token');
return json.access_token;
}
async listGroupMembers(groupEmail: string): Promise<string[]> {
const token = await this.accessToken();
const emails: string[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
`https://admin.googleapis.com/admin/directory/v1/groups/${encodeURIComponent(groupEmail)}/members`,
);
url.searchParams.set('maxResults', '200');
// Flatten nested groups to their underlying users (the documented contract).
// Derived members come back as type USER; the nested GROUP rows themselves
// are skipped below so they are never pushed as a phantom (un-resolvable) user.
url.searchParams.set('includeDerivedMembership', 'true');
if (pageToken) url.searchParams.set('pageToken', pageToken);
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
if (!res.ok)
throw new Error(`Directory members read failed for ${groupEmail}: ${res.status}`);
const json = (await res.json()) as {
members?: { email?: string; type?: string; status?: string }[];
nextPageToken?: string;
};
for (const m of json.members ?? []) {
// Only real users carry a tier scope; GROUP/derived rows resolve to no
// Cognito user and would be silent no-op adds, so drop them here.
if (m.email && m.status !== 'SUSPENDED' && m.type !== 'GROUP') emails.push(m.email);
}
pageToken = json.nextPageToken;
} while (pageToken);
return emails;
}
}