mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 06:33:16 +00:00
96 lines
3.9 KiB
TypeScript
96 lines
3.9 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 };
|