sh-mcp/auth/pre-token-gen/src/index.ts

96 lines
3.9 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
/**
* Cognito V2 pre-token-generation Lambda — SUPPRESS-ONLY scope enforcement.
*
* On every access-token mint it strips the tier scopes the caller's groups do
* not grant (and falls back to base `ops:read` when group state is stale). It
* NEVER adds a scope: `AllowedOAuthScopes` on each app client remains the real
* per-tier ceiling (design.md §2.3; memory: Cognito spike gotcha 3).
*
* Requires Cognito user pool feature plan ESSENTIALS or higher — Lite silently
* ignores the V2 trigger (memory: Cognito spike gotcha 1).
*/
import { computeScopesToSuppress, ALL_TIER_SCOPES, BASE_SCOPES } from './scopes.js';
import { readLastSuccessfulSyncMs, isSyncFresh } from './sync-state.js';
import { isSubDenied } from './deny-list.js';
/** Minimal shape of the V2/V3 pre-token event fields we read/write. */
interface PreTokenEvent {
callerContext?: { clientId?: string };
userName?: string;
request?: {
groupConfiguration?: { groupsToOverride?: string[] };
userAttributes?: { sub?: string };
};
response?: Record<string, unknown>;
}
export async function handler(event: PreTokenEvent): Promise<PreTokenEvent> {
const groups = event.request?.groupConfiguration?.groupsToOverride ?? [];
// Read env per-invocation (not at module load) so the Lambda picks up its
// configured tables and so the policy is exercisable in tests.
const syncStateTable = process.env['SYNC_STATE_TABLE'];
const denyListTable = process.env['DENY_LIST_TABLE'];
// Hard-revocation overlay: a deny-listed `sub` is dropped to NO tier scopes
// immediately, ahead of the next group sync. Checked first — it short-circuits
// entitlement entirely. (Fail-open on read error; see deny-list.ts.)
const sub = event.request?.userAttributes?.sub;
if (denyListTable && (await isSubDenied(denyListTable, sub))) {
console.warn(
JSON.stringify({ event: 'pre_token_deny_listed', clientId: event.callerContext?.clientId }),
);
return writeSuppression(event, [...ALL_TIER_SCOPES]);
}
// Determine group-sync freshness. A missing table env, an unreadable marker, or
// a stale timestamp all resolve to "not fresh" → fail closed to base scopes.
let syncFresh = false;
if (syncStateTable) {
const lastSyncMs = await readLastSuccessfulSyncMs(syncStateTable);
syncFresh = isSyncFresh(lastSyncMs, Date.now());
}
if (!syncFresh) {
// Structured log so a CloudWatch metric filter can alarm on fail-closed events
// (a sustained outage means group revocations aren't propagating).
console.warn(
JSON.stringify({
event: 'pre_token_fail_closed',
reason: syncStateTable ? 'group_sync_stale_or_unreadable' : 'sync_state_table_unset',
clientId: event.callerContext?.clientId,
droppedTo: BASE_SCOPES,
}),
);
}
return writeSuppression(event, computeScopesToSuppress(groups, syncFresh));
}
/**
* Write the suppress-only override onto the event. Centralizes the single place a
* response is constructed so the SUPPRESS-ONLY invariant is enforced once.
*/
function writeSuppression(event: PreTokenEvent, scopesToSuppress: string[]): PreTokenEvent {
// SUPPRESS-ONLY: scopesToAdd is intentionally omitted. Do not ever set it.
const accessTokenGeneration: { scopesToSuppress: string[] } = { scopesToSuppress };
// Executable enforcement of the load-bearing invariant: a future edit that
// ever introduces `scopesToAdd` must fail the invocation (no token minted →
// PreTokenErrorsAlarm fires) rather than silently widen a caller's scope.
if ('scopesToAdd' in accessTokenGeneration) {
throw new Error('pre-token policy violated SUPPRESS-ONLY invariant: scopesToAdd is set');
}
event.response = {
...event.response,
claimsAndScopeOverrideDetails: {
accessTokenGeneration,
},
};
return event;
}
/** Exported for tests: the full issued scope set this policy governs. */
export { ALL_TIER_SCOPES };