mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 06:33:16 +00:00
Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas (#4)
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
* 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.
This commit is contained in:
parent
22c09e99fe
commit
b5e604dabe
30 changed files with 2469 additions and 7 deletions
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
|
|
@ -16,6 +16,8 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
enable-qemu: true
|
enable-qemu: true
|
||||||
# Phase 0b ships no CDK app (no cdk.json / stacks); infra lands in Phase 1.
|
# Phase 2a adds the root cdk.json → infra/ app (sh-mcp-auth). The reusable
|
||||||
run-cdk-synth: false
|
# runs `npx cdk synth` at the repo root; aws-cdk is a root devDep so npx
|
||||||
|
# resolves the pinned CLI rather than fetching a stray standalone.
|
||||||
|
run-cdk-synth: true
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
|
||||||
39
README.md
39
README.md
|
|
@ -5,9 +5,42 @@ Sea Haven's proprietary integrations as tools, plus the **Cognito/Google auth br
|
||||||
**rebuilt scheduled jobs**. This service replaces `seahaven-slack-bot` and `exec-aide`, which are
|
**rebuilt scheduled jobs**. This service replaces `seahaven-slack-bot` and `exec-aide`, which are
|
||||||
deprecated completely; the conversational surface becomes a configurable Slack task agent.
|
deprecated completely; the conversational surface becomes a configurable Slack task agent.
|
||||||
|
|
||||||
> **Status: DESIGN / PLANNING. Not built. No stack deployed.**
|
> **Status: BUILDING. Platform + auth substrate built; nothing deployed to AWS yet.**
|
||||||
> The full design, auth architecture, scope matrix, and build plan live in
|
> Phase 0b (monorepo + `@sh-mcp/shared` core), Phase 1 (runnable `sh-mcp-ops` /
|
||||||
> [`docs/design.md`](docs/design.md). Read it before writing any code.
|
> `sh-mcp-finance` over MCP + OpenAPI), and Phase 2a (the Cognito auth substrate
|
||||||
|
> CDK stack + pre-token/group-sync Lambdas) are on `main`. The servers run
|
||||||
|
> locally (`SH_MCP_ENV=local`); `SH_MCP_ENV=aws` is wired but not yet deployed.
|
||||||
|
> The authoritative spec is [`docs/design.md`](docs/design.md).
|
||||||
|
|
||||||
|
## Auth substrate (Phase 2a — `infra/` + `auth/`)
|
||||||
|
|
||||||
|
`infra/` is the root CDK app; `cdk synth` builds the **`sh-mcp-auth`** stack:
|
||||||
|
a Google-federated Cognito user pool (ESSENTIALS feature plan), per-tier app
|
||||||
|
clients whose `AllowedOAuthScopes` are the trust-tier boundary
|
||||||
|
(`sh-agentforce-ops` / `-finance` / `-exec`), a **suppress-only** V2 pre-token
|
||||||
|
Lambda (`auth/pre-token-gen`), a 5-minute group-sync Lambda
|
||||||
|
(`auth/group-sync`), and the sync-state + deny-list tables.
|
||||||
|
|
||||||
|
**Revocation has two layers.** Group membership (Google → Cognito, 5-min cadence)
|
||||||
|
is the primary entitlement control, with a fail-**closed** 30-minute freshness
|
||||||
|
window: if group-sync stalls, the pre-token Lambda drops every caller to base
|
||||||
|
`ops:read`. The `sh-mcp-deny-list` table is a hard-kill **overlay** the pre-token
|
||||||
|
Lambda consults on every mint — a `sub` listed there is stripped to no tier scopes
|
||||||
|
immediately (TTL-expiring), ahead of the next sync. The deny-list read is fail-**open**
|
||||||
|
(a DynamoDB blip logs `deny_list_read_failed` and does not lock everyone out, since
|
||||||
|
group membership + its fail-closed window remain authoritative). The finance app
|
||||||
|
client additionally caps its refresh token at 8h (vs 30d for ops/exec) so a stolen
|
||||||
|
finance refresh token cannot ride past the short access-token TTL for a month.
|
||||||
|
|
||||||
|
**Deploy is gated on two manual prerequisites (owner: Adam):**
|
||||||
|
|
||||||
|
1. Google Cloud OAuth 2.0 web client (Cognito federation) → Secrets Manager `sh-mcp/google-oidc` (`{client_id, client_secret}`).
|
||||||
|
2. Google Workspace service account w/ domain-wide delegation (Directory `groups.readonly`) → Secrets Manager `sh-mcp/google-directory-sa`.
|
||||||
|
|
||||||
|
**App-client OAuth callback URLs are a placeholder** (`-c callbackUrls=...`)
|
||||||
|
until the conversational surface (Agentforce vs Bolt) is chosen — the substrate
|
||||||
|
is surface-agnostic. The optional `alias/seahaven-logs` CMK for the Lambda log
|
||||||
|
groups is supplied at deploy via `-c logsKmsArn=...`.
|
||||||
|
|
||||||
## Shape (planned)
|
## Shape (planned)
|
||||||
|
|
||||||
|
|
|
||||||
32
auth/group-sync/package.json
Normal file
32
auth/group-sync/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "@sh-mcp/auth-group-sync",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Group-sync Lambda — mirror Google Group membership into Cognito groups (design.md §2.3)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-cognito-identity-provider": "^3.700.0",
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.700.0",
|
||||||
|
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.700.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "3.0.2",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
122
auth/group-sync/src/aws.ts
Normal file
122
auth/group-sync/src/aws.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* AWS-backed implementations of the group-sync target + sync-state writer.
|
||||||
|
* Externalized at bundle time; only exercised in deployed (aws) runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CognitoIdentityProviderClient,
|
||||||
|
CreateGroupCommand,
|
||||||
|
ListUsersInGroupCommand,
|
||||||
|
ListUsersCommand,
|
||||||
|
AdminAddUserToGroupCommand,
|
||||||
|
AdminRemoveUserFromGroupCommand,
|
||||||
|
} from '@aws-sdk/client-cognito-identity-provider';
|
||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
|
||||||
|
import type { CognitoGroupTarget, SyncStateWriter } from './clients.js';
|
||||||
|
import { normalizeEmail } from './groups.js';
|
||||||
|
import { SYNC_STATE_PK } from './constants.js';
|
||||||
|
|
||||||
|
/** Resolves emails ↔ Cognito usernames and reconciles group membership. */
|
||||||
|
export class CognitoGroupSync implements CognitoGroupTarget {
|
||||||
|
constructor(
|
||||||
|
private readonly userPoolId: string,
|
||||||
|
private readonly cognito = new CognitoIdentityProviderClient({}),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private emailOf(attrs: { Name?: string; Value?: string }[] | undefined): string | undefined {
|
||||||
|
return attrs?.find((a) => a.Name === 'email')?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureGroup(groupName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.cognito.send(
|
||||||
|
new CreateGroupCommand({ GroupName: groupName, UserPoolId: this.userPoolId }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Already exists is the expected idempotent path; rethrow anything else.
|
||||||
|
if ((err as { name?: string }).name !== 'GroupExistsException') throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMembers(groupName: string): Promise<string[]> {
|
||||||
|
const emails: string[] = [];
|
||||||
|
let token: string | undefined;
|
||||||
|
do {
|
||||||
|
const res = await this.cognito.send(
|
||||||
|
new ListUsersInGroupCommand({
|
||||||
|
GroupName: groupName,
|
||||||
|
UserPoolId: this.userPoolId,
|
||||||
|
NextToken: token,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const u of res.Users ?? []) {
|
||||||
|
const email = this.emailOf(u.Attributes);
|
||||||
|
if (email) emails.push(email);
|
||||||
|
}
|
||||||
|
token = res.NextToken;
|
||||||
|
} while (token);
|
||||||
|
return emails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the Cognito username for a Workspace email (federated user). */
|
||||||
|
private async usernameForEmail(email: string): Promise<string | undefined> {
|
||||||
|
const res = await this.cognito.send(
|
||||||
|
new ListUsersCommand({
|
||||||
|
UserPoolId: this.userPoolId,
|
||||||
|
Filter: `email = "${normalizeEmail(email)}"`,
|
||||||
|
Limit: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return res.Users?.[0]?.Username;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(groupName: string, email: string): Promise<void> {
|
||||||
|
const username = await this.usernameForEmail(email);
|
||||||
|
if (!username) return; // user hasn't federated into the pool yet; next sync will add them
|
||||||
|
await this.cognito.send(
|
||||||
|
new AdminAddUserToGroupCommand({
|
||||||
|
GroupName: groupName,
|
||||||
|
UserPoolId: this.userPoolId,
|
||||||
|
Username: username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(groupName: string, email: string): Promise<void> {
|
||||||
|
const username = await this.usernameForEmail(email);
|
||||||
|
if (!username) return;
|
||||||
|
await this.cognito.send(
|
||||||
|
new AdminRemoveUserFromGroupCommand({
|
||||||
|
GroupName: groupName,
|
||||||
|
UserPoolId: this.userPoolId,
|
||||||
|
Username: username,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the freshness marker the pre-token Lambda reads. */
|
||||||
|
export class DynamoSyncStateWriter implements SyncStateWriter {
|
||||||
|
private readonly doc: DynamoDBDocumentClient;
|
||||||
|
constructor(
|
||||||
|
private readonly tableName: string,
|
||||||
|
client: DynamoDBClient = new DynamoDBClient({}),
|
||||||
|
) {
|
||||||
|
this.doc = DynamoDBDocumentClient.from(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeLastSuccessfulSync(epochMs: number): Promise<void> {
|
||||||
|
await this.doc.send(
|
||||||
|
new PutCommand({
|
||||||
|
TableName: this.tableName,
|
||||||
|
Item: {
|
||||||
|
pk: SYNC_STATE_PK,
|
||||||
|
lastSuccessfulSyncMs: epochMs,
|
||||||
|
updatedAt: new Date(epochMs).toISOString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
auth/group-sync/src/clients.ts
Normal file
112
auth/group-sync/src/clients.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
auth/group-sync/src/constants.ts
Normal file
5
auth/group-sync/src/constants.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* Partition key of the singleton sync-state item. MUST match the value the
|
||||||
|
* pre-token Lambda reads (`@sh-mcp/auth-pre-token-gen` sync-state `SYNC_STATE_PK`).
|
||||||
|
*/
|
||||||
|
export const SYNC_STATE_PK = 'group-sync';
|
||||||
61
auth/group-sync/src/groups.ts
Normal file
61
auth/group-sync/src/groups.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* The Google Group → Cognito group mapping this sync reconciles, and the pure
|
||||||
|
* membership-diff logic. Google Groups are the single source of truth for
|
||||||
|
* entitlement (design.md §2.3); this Lambda mirrors their membership into the
|
||||||
|
* matching Cognito groups every 5 minutes so the pre-token Lambda can map
|
||||||
|
* Cognito group → suppressed scopes without a hot-path Directory call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Workspace domain the managed groups live under. Overridable via env at deploy. */
|
||||||
|
export const DEFAULT_DOMAIN = 'seahavenind.com';
|
||||||
|
|
||||||
|
/** The four managed groups (Cognito group name === Google Group local-part). */
|
||||||
|
export const MANAGED_GROUP_NAMES = [
|
||||||
|
'sh-mcp-ops',
|
||||||
|
'sh-mcp-assistant',
|
||||||
|
'sh-mcp-finance',
|
||||||
|
'sh-mcp-admin',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ManagedGroupName = (typeof MANAGED_GROUP_NAMES)[number];
|
||||||
|
|
||||||
|
export interface ManagedGroup {
|
||||||
|
/** Cognito group name. */
|
||||||
|
cognito: ManagedGroupName;
|
||||||
|
/** Fully-qualified Google Group address. */
|
||||||
|
googleEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the managed-group list for a workspace domain. */
|
||||||
|
export function managedGroups(domain: string = DEFAULT_DOMAIN): ManagedGroup[] {
|
||||||
|
return MANAGED_GROUP_NAMES.map((cognito) => ({ cognito, googleEmail: `${cognito}@${domain}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize an email for set comparison (Google/Cognito casing is not stable). */
|
||||||
|
export function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipDiff {
|
||||||
|
toAdd: string[];
|
||||||
|
toRemove: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the membership changes to make a Cognito group match the Google group.
|
||||||
|
* Comparison is case-insensitive; returned values are normalized (lowercased).
|
||||||
|
*
|
||||||
|
* @param current members currently in the Cognito group.
|
||||||
|
* @param desired members the Google group says should be present.
|
||||||
|
*/
|
||||||
|
export function computeMembershipDiff(
|
||||||
|
current: readonly string[],
|
||||||
|
desired: readonly string[],
|
||||||
|
): MembershipDiff {
|
||||||
|
const cur = new Set(current.map(normalizeEmail));
|
||||||
|
const des = new Set(desired.map(normalizeEmail));
|
||||||
|
return {
|
||||||
|
toAdd: [...des].filter((e) => !cur.has(e)),
|
||||||
|
toRemove: [...cur].filter((e) => !des.has(e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
51
auth/group-sync/src/index.ts
Normal file
51
auth/group-sync/src/index.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Group-sync Lambda entrypoint — runs every 5 minutes (EventBridge).
|
||||||
|
*
|
||||||
|
* Mirrors Google Group membership into the matching Cognito groups and, on full
|
||||||
|
* success, writes the freshness marker the pre-token Lambda fails closed on. Any
|
||||||
|
* error propagates so the CloudWatch alarm fires and the marker stays stale.
|
||||||
|
*
|
||||||
|
* Reads the Google service-account credentials from Secrets Manager on cold
|
||||||
|
* start (never an env var — secrets-and-config handbook rule).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
||||||
|
|
||||||
|
import { GoogleDirectoryReader, type GoogleServiceAccount } from './clients.js';
|
||||||
|
import { CognitoGroupSync, DynamoSyncStateWriter } from './aws.js';
|
||||||
|
import { runSync } from './sync.js';
|
||||||
|
import { DEFAULT_DOMAIN } from './groups.js';
|
||||||
|
|
||||||
|
let cachedSa: GoogleServiceAccount | undefined;
|
||||||
|
|
||||||
|
async function loadServiceAccount(secretArn: string): Promise<GoogleServiceAccount> {
|
||||||
|
if (cachedSa) return cachedSa;
|
||||||
|
const sm = new SecretsManagerClient({});
|
||||||
|
const res = await sm.send(new GetSecretValueCommand({ SecretId: secretArn }));
|
||||||
|
if (!res.SecretString) throw new Error('Google SA secret has no SecretString');
|
||||||
|
cachedSa = JSON.parse(res.SecretString) as GoogleServiceAccount;
|
||||||
|
return cachedSa;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) throw new Error(`Missing required env var ${name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handler(): Promise<{ ok: true; results: unknown }> {
|
||||||
|
const userPoolId = requireEnv('USER_POOL_ID');
|
||||||
|
const syncStateTable = requireEnv('SYNC_STATE_TABLE');
|
||||||
|
const saSecretArn = requireEnv('GOOGLE_SA_SECRET_ARN');
|
||||||
|
const domain = process.env['WORKSPACE_DOMAIN'] ?? DEFAULT_DOMAIN;
|
||||||
|
|
||||||
|
const sa = await loadServiceAccount(saSecretArn);
|
||||||
|
const results = await runSync({
|
||||||
|
directory: new GoogleDirectoryReader(sa),
|
||||||
|
cognito: new CognitoGroupSync(userPoolId),
|
||||||
|
syncState: new DynamoSyncStateWriter(syncStateTable),
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify({ event: 'group_sync_ok', results }));
|
||||||
|
return { ok: true, results };
|
||||||
|
}
|
||||||
61
auth/group-sync/src/sync.ts
Normal file
61
auth/group-sync/src/sync.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
39
auth/group-sync/test/groups.test.ts
Normal file
39
auth/group-sync/test/groups.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
computeMembershipDiff,
|
||||||
|
managedGroups,
|
||||||
|
normalizeEmail,
|
||||||
|
MANAGED_GROUP_NAMES,
|
||||||
|
} from '../src/groups.js';
|
||||||
|
|
||||||
|
describe('computeMembershipDiff', () => {
|
||||||
|
it('adds missing and removes extra, case-insensitively', () => {
|
||||||
|
const diff = computeMembershipDiff(
|
||||||
|
['Adam@seahavenind.com', 'old@seahavenind.com'],
|
||||||
|
['adam@seahavenind.com', 'new@seahavenind.com'],
|
||||||
|
);
|
||||||
|
expect(diff.toAdd).toEqual(['new@seahavenind.com']);
|
||||||
|
expect(diff.toRemove).toEqual(['old@seahavenind.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops when membership already matches (ignoring case/whitespace)', () => {
|
||||||
|
const diff = computeMembershipDiff([' A@x.com '], ['a@x.com']);
|
||||||
|
expect(diff.toAdd).toEqual([]);
|
||||||
|
expect(diff.toRemove).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('managedGroups', () => {
|
||||||
|
it('maps each Cognito group to its Google address on the given domain', () => {
|
||||||
|
const g = managedGroups('example.com');
|
||||||
|
expect(g.map((x) => x.cognito)).toEqual([...MANAGED_GROUP_NAMES]);
|
||||||
|
expect(g[0]).toEqual({ cognito: 'sh-mcp-ops', googleEmail: 'sh-mcp-ops@example.com' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeEmail', () => {
|
||||||
|
it('lowercases and trims', () => {
|
||||||
|
expect(normalizeEmail(' Foo@Bar.COM ')).toBe('foo@bar.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
80
auth/group-sync/test/sync.test.ts
Normal file
80
auth/group-sync/test/sync.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
auth/group-sync/tsconfig.json
Normal file
9
auth/group-sync/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declarationDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
27
auth/pre-token-gen/package.json
Normal file
27
auth/pre-token-gen/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "@sh-mcp/auth-pre-token-gen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Cognito V2 pre-token-generation Lambda — suppress-only scope enforcement (design.md §2.3)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.700.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.700.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "3.0.2",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
auth/pre-token-gen/src/deny-list.ts
Normal file
51
auth/pre-token-gen/src/deny-list.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Reads the hard-revocation deny-list the pre-token Lambda consults at mint time.
|
||||||
|
*
|
||||||
|
* The group-sync path (Google → Cognito groups, 5-min cadence) is the PRIMARY
|
||||||
|
* entitlement control; this deny-list is a fast-kill OVERLAY for incident response
|
||||||
|
* ("revoke this compromised `sub` now") that does not wait for the next sync. An
|
||||||
|
* entry keyed by the user's `sub` means: suppress every tier scope on this mint,
|
||||||
|
* dropping the principal to no MCP access until the (TTL'd) entry expires.
|
||||||
|
*
|
||||||
|
* Failure posture is deliberately fail-OPEN: a deny-list read error resolves to
|
||||||
|
* "not denied" (logged loudly for a metric-filter alarm) rather than denying, so
|
||||||
|
* a DynamoDB blip cannot lock every principal out of token issuance. The primary
|
||||||
|
* group-membership control — and its own fail-CLOSED 30-min freshness window —
|
||||||
|
* still governs entitlement. (design.md §2.3; security-review C2.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
|
||||||
|
let cached: DynamoDBDocumentClient | undefined;
|
||||||
|
|
||||||
|
function doc(): DynamoDBDocumentClient {
|
||||||
|
cached ??= DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff `sub` has an active deny-list entry. A read failure resolves to
|
||||||
|
* `false` (fail OPEN — see module note) after a structured warn log so the
|
||||||
|
* outage is observable. An empty/absent `sub` is never denied.
|
||||||
|
*/
|
||||||
|
export async function isSubDenied(
|
||||||
|
tableName: string,
|
||||||
|
sub: string | undefined,
|
||||||
|
client: DynamoDBDocumentClient = doc(),
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!sub) return false;
|
||||||
|
try {
|
||||||
|
const res = await client.send(new GetCommand({ TableName: tableName, Key: { sub } }));
|
||||||
|
return res.Item !== undefined;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'deny_list_read_failed',
|
||||||
|
reason: err instanceof Error ? err.message : 'unknown',
|
||||||
|
failedOpen: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
auth/pre-token-gen/src/index.ts
Normal file
95
auth/pre-token-gen/src/index.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
81
auth/pre-token-gen/src/scopes.ts
Normal file
81
auth/pre-token-gen/src/scopes.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Suppress-only scope policy for the Cognito V2 pre-token-generation Lambda.
|
||||||
|
*
|
||||||
|
* HARD RULE (design.md §2.3, agentforce-plan §0.1 B4; memory: Cognito spike gotcha 3):
|
||||||
|
* the pre-token Lambda is SUPPRESS-ONLY. It MUST NEVER emit `scopesToAdd` for a
|
||||||
|
* tier scope — `AllowedOAuthScopes` on each app client stays the real per-tier
|
||||||
|
* ceiling, and a V2 Lambda's `scopesToAdd` is NOT capped by that ceiling, so
|
||||||
|
* adding here would silently break the trust-tier boundary. Entitlement is
|
||||||
|
* enforced by SUPPRESSING every tier scope the caller's groups do not grant.
|
||||||
|
*
|
||||||
|
* Scopes are resource-server-prefixed exactly as they appear in a Cognito access
|
||||||
|
* token (e.g. `sh-mcp-ops/ops:read`), matching what the servers parse
|
||||||
|
* (`@sh-mcp/shared` `extractScopes`, scopePrefix `sh-mcp-ops` / `sh-mcp-finance`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Resource-server-prefixed scope strings as they appear in the access token. */
|
||||||
|
export const OPS_READ = 'sh-mcp-ops/ops:read';
|
||||||
|
export const OPS_TASKS = 'sh-mcp-ops/ops:tasks';
|
||||||
|
export const OPS_GMAIL = 'sh-mcp-ops/gmail:self';
|
||||||
|
export const OPS_CALENDAR = 'sh-mcp-ops/calendar:self';
|
||||||
|
export const FINANCE_READ = 'sh-mcp-finance/finance:read';
|
||||||
|
export const FINANCE_ADMIN = 'sh-mcp-finance/finance:admin';
|
||||||
|
|
||||||
|
/** Every tier scope the platform issues. Suppression is computed against this set. */
|
||||||
|
export const ALL_TIER_SCOPES: readonly string[] = [
|
||||||
|
OPS_READ,
|
||||||
|
OPS_TASKS,
|
||||||
|
OPS_GMAIL,
|
||||||
|
OPS_CALENDAR,
|
||||||
|
FINANCE_READ,
|
||||||
|
FINANCE_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimum entitlement the platform falls back to when group state cannot be
|
||||||
|
* trusted (stale sync). Deliberately just `ops:read` — fail CLOSED: a finance
|
||||||
|
* admin gets read-only ops access during a group-sync outage, never more.
|
||||||
|
*/
|
||||||
|
export const BASE_SCOPES: readonly string[] = [OPS_READ];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cognito group (synced 1:1 from the Google Group of the same name) → the tier
|
||||||
|
* scopes that group grants. A user in several groups gets the union (design.md
|
||||||
|
* §2.3). Membership is the single control point; this Lambda only ever removes
|
||||||
|
* scopes the union does not include.
|
||||||
|
*/
|
||||||
|
export const GROUP_SCOPES: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
'sh-mcp-ops': [OPS_READ],
|
||||||
|
'sh-mcp-assistant': [OPS_READ, OPS_TASKS, OPS_GMAIL, OPS_CALENDAR],
|
||||||
|
'sh-mcp-finance': [OPS_READ, FINANCE_READ],
|
||||||
|
'sh-mcp-admin': [OPS_READ, OPS_TASKS, OPS_GMAIL, OPS_CALENDAR, FINANCE_READ, FINANCE_ADMIN],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of the scopes granted by the caller's groups. Unknown group names are
|
||||||
|
* ignored (they grant nothing), so a stray Cognito group can never widen access.
|
||||||
|
*/
|
||||||
|
export function entitledScopes(groups: readonly string[]): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const s of GROUP_SCOPES[g] ?? []) out.add(s);
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suppress-only decision. Returns the tier scopes to strip from the token:
|
||||||
|
* every issued tier scope the caller is NOT entitled to.
|
||||||
|
*
|
||||||
|
* @param groups the caller's Cognito groups (from `groupConfiguration`).
|
||||||
|
* @param syncFresh whether group state is fresh enough to trust. When false we
|
||||||
|
* fall back to {@link BASE_SCOPES} (fail closed), suppressing
|
||||||
|
* everything above `ops:read` regardless of group membership.
|
||||||
|
*/
|
||||||
|
export function computeScopesToSuppress(groups: readonly string[], syncFresh: boolean): string[] {
|
||||||
|
const entitled = new Set(syncFresh ? entitledScopes(groups) : BASE_SCOPES);
|
||||||
|
// Suppress every issued tier scope the caller is not entitled to. Suppressing a
|
||||||
|
// scope that is not present is a harmless no-op, so this is safe regardless of
|
||||||
|
// which scopes the client actually requested.
|
||||||
|
return ALL_TIER_SCOPES.filter((s) => !entitled.has(s));
|
||||||
|
}
|
||||||
54
auth/pre-token-gen/src/sync-state.ts
Normal file
54
auth/pre-token-gen/src/sync-state.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Reads the group-sync freshness marker the pre-token Lambda fails closed on.
|
||||||
|
*
|
||||||
|
* The group-sync Lambda writes `lastSuccessfulSyncMs` (epoch ms) to a single
|
||||||
|
* item in the sync-state table on every successful Google→Cognito sync. If that
|
||||||
|
* marker is missing or older than {@link MAX_SYNC_AGE_MS}, group membership may
|
||||||
|
* be stale (a revoked user could still appear entitled), so the pre-token Lambda
|
||||||
|
* drops to base scopes (design.md §2.3 fail-closed; agentforce-plan §0.1 CR-5).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
|
||||||
|
/** Stale threshold: 30 min. A 5-min sync cadence means >6 missed runs is a fault. */
|
||||||
|
export const MAX_SYNC_AGE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Fixed partition key of the singleton sync-state item. */
|
||||||
|
export const SYNC_STATE_PK = 'group-sync';
|
||||||
|
|
||||||
|
let cached: DynamoDBDocumentClient | undefined;
|
||||||
|
|
||||||
|
function doc(): DynamoDBDocumentClient {
|
||||||
|
cached ??= DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the last successful sync timestamp (epoch ms), or `null` if the marker
|
||||||
|
* is absent or unreadable. A read failure resolves to `null` (treated as stale)
|
||||||
|
* so an outage of the sync-state table fails CLOSED, never open.
|
||||||
|
*/
|
||||||
|
export async function readLastSuccessfulSyncMs(
|
||||||
|
tableName: string,
|
||||||
|
client: DynamoDBDocumentClient = doc(),
|
||||||
|
): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await client.send(
|
||||||
|
new GetCommand({ TableName: tableName, Key: { pk: SYNC_STATE_PK } }),
|
||||||
|
);
|
||||||
|
const ts = res.Item?.['lastSuccessfulSyncMs'];
|
||||||
|
return typeof ts === 'number' && Number.isFinite(ts) ? ts : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `lastSyncMs` is present and within {@link MAX_SYNC_AGE_MS} of `nowMs`. */
|
||||||
|
export function isSyncFresh(
|
||||||
|
lastSyncMs: number | null,
|
||||||
|
nowMs: number,
|
||||||
|
maxAgeMs: number = MAX_SYNC_AGE_MS,
|
||||||
|
): boolean {
|
||||||
|
return lastSyncMs !== null && nowMs - lastSyncMs <= maxAgeMs && lastSyncMs <= nowMs;
|
||||||
|
}
|
||||||
38
auth/pre-token-gen/test/deny-list.test.ts
Normal file
38
auth/pre-token-gen/test/deny-list.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { isSubDenied } from '../src/deny-list.js';
|
||||||
|
|
||||||
|
function fakeClient(send: () => Promise<unknown>) {
|
||||||
|
return { send: vi.fn(send) } as unknown as Parameters<typeof isSubDenied>[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isSubDenied', () => {
|
||||||
|
it('returns true when the sub has a deny-list item', async () => {
|
||||||
|
const client = fakeClient(async () => ({ Item: { sub: 'abc' } }));
|
||||||
|
expect(await isSubDenied('sh-mcp-deny-list', 'abc', client)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the sub has no item', async () => {
|
||||||
|
const client = fakeClient(async () => ({}));
|
||||||
|
expect(await isSubDenied('sh-mcp-deny-list', 'abc', client)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never denies an absent/empty sub (and does not hit DDB)', async () => {
|
||||||
|
const client = fakeClient(async () => ({ Item: { sub: '' } }));
|
||||||
|
expect(await isSubDenied('sh-mcp-deny-list', undefined, client)).toBe(false);
|
||||||
|
expect((client as unknown as { send: { mock: { calls: unknown[] } } }).send.mock.calls).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILS OPEN on a read error: a DDB blip resolves to not-denied (logged)', async () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
const client = fakeClient(async () => {
|
||||||
|
throw new Error('throttled');
|
||||||
|
});
|
||||||
|
expect(await isSubDenied('sh-mcp-deny-list', 'abc', client)).toBe(false);
|
||||||
|
expect(warn).toHaveBeenCalledOnce();
|
||||||
|
expect(warn.mock.calls[0]?.[0]).toContain('deny_list_read_failed');
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
98
auth/pre-token-gen/test/handler.test.ts
Normal file
98
auth/pre-token-gen/test/handler.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the sync-state + deny-list DDB reads so the handler test exercises pure
|
||||||
|
// orchestration. vi.hoisted keeps the mock fns available to the hoisted factories.
|
||||||
|
const { readMock, denyMock } = vi.hoisted(() => ({ readMock: vi.fn(), denyMock: vi.fn() }));
|
||||||
|
vi.mock('../src/sync-state.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/sync-state.js')>();
|
||||||
|
return { ...actual, readLastSuccessfulSyncMs: readMock };
|
||||||
|
});
|
||||||
|
vi.mock('../src/deny-list.js', () => ({ isSubDenied: denyMock }));
|
||||||
|
|
||||||
|
import { handler } from '../src/index.js';
|
||||||
|
import { FINANCE_READ, FINANCE_ADMIN, OPS_READ, ALL_TIER_SCOPES } from '../src/scopes.js';
|
||||||
|
|
||||||
|
function event(groups: string[], clientId = 'sh-agentforce-finance', sub = 'user-sub-1') {
|
||||||
|
return {
|
||||||
|
callerContext: { clientId },
|
||||||
|
request: { groupConfiguration: { groupsToOverride: groups }, userAttributes: { sub } },
|
||||||
|
response: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessGen(res: Awaited<ReturnType<typeof handler>>) {
|
||||||
|
const d = res.response?.['claimsAndScopeOverrideDetails'] as
|
||||||
|
| { accessTokenGeneration?: { scopesToSuppress?: string[]; scopesToAdd?: string[] } }
|
||||||
|
| undefined;
|
||||||
|
return d?.accessTokenGeneration ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pre-token handler', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-26T18:00:00Z'));
|
||||||
|
process.env['SYNC_STATE_TABLE'] = 'sh-mcp-sync-state';
|
||||||
|
process.env['DENY_LIST_TABLE'] = 'sh-mcp-deny-list';
|
||||||
|
readMock.mockReset();
|
||||||
|
denyMock.mockReset();
|
||||||
|
denyMock.mockResolvedValue(false); // not denied unless a test says otherwise
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('HARD REVOCATION: a deny-listed sub is stripped to NO tier scopes, ignoring groups', async () => {
|
||||||
|
readMock.mockResolvedValue(Date.now()); // fresh sync — irrelevant once denied
|
||||||
|
denyMock.mockResolvedValue(true);
|
||||||
|
const res = await handler(event(['sh-mcp-admin']));
|
||||||
|
const gen = accessGen(res);
|
||||||
|
expect(gen.scopesToAdd).toBeUndefined();
|
||||||
|
// Every issued tier scope is suppressed → the principal keeps none.
|
||||||
|
for (const s of ALL_TIER_SCOPES) expect(gen.scopesToSuppress).toContain(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deny-list is skipped when DENY_LIST_TABLE is unset (never blocks issuance on missing config)', async () => {
|
||||||
|
delete process.env['DENY_LIST_TABLE'];
|
||||||
|
readMock.mockResolvedValue(Date.now());
|
||||||
|
const res = await handler(event(['sh-mcp-finance']));
|
||||||
|
expect(denyMock).not.toHaveBeenCalled();
|
||||||
|
expect(accessGen(res).scopesToSuppress).not.toContain(FINANCE_READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER emits scopesToAdd, on any path', async () => {
|
||||||
|
readMock.mockResolvedValue(Date.now()); // fresh
|
||||||
|
for (const groups of [[], ['sh-mcp-ops'], ['sh-mcp-admin']]) {
|
||||||
|
const res = await handler(event(groups));
|
||||||
|
expect(accessGen(res).scopesToAdd).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh sync: finance group keeps finance:read, loses finance:admin', async () => {
|
||||||
|
readMock.mockResolvedValue(Date.now() - 60_000); // 1 min old → fresh
|
||||||
|
const res = await handler(event(['sh-mcp-finance']));
|
||||||
|
const suppress = accessGen(res).scopesToSuppress ?? [];
|
||||||
|
expect(suppress).toContain(FINANCE_ADMIN);
|
||||||
|
expect(suppress).not.toContain(FINANCE_READ);
|
||||||
|
expect(suppress).not.toContain(OPS_READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: stale sync drops an admin to base ops:read', async () => {
|
||||||
|
readMock.mockResolvedValue(Date.now() - 60 * 60_000); // 60 min old → stale
|
||||||
|
const res = await handler(event(['sh-mcp-admin']));
|
||||||
|
const suppress = accessGen(res).scopesToSuppress ?? [];
|
||||||
|
expect(suppress).toContain(FINANCE_READ);
|
||||||
|
expect(suppress).toContain(FINANCE_ADMIN);
|
||||||
|
expect(suppress).not.toContain(OPS_READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: missing sync marker (null) drops to base', async () => {
|
||||||
|
readMock.mockResolvedValue(null);
|
||||||
|
const res = await handler(event(['sh-mcp-finance']));
|
||||||
|
expect(accessGen(res).scopesToSuppress).toContain(FINANCE_READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: unset sync-state table never reads DDB and drops to base', async () => {
|
||||||
|
delete process.env['SYNC_STATE_TABLE'];
|
||||||
|
const res = await handler(event(['sh-mcp-admin']));
|
||||||
|
expect(readMock).not.toHaveBeenCalled();
|
||||||
|
expect(accessGen(res).scopesToSuppress).toContain(FINANCE_ADMIN);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
auth/pre-token-gen/test/scopes.test.ts
Normal file
75
auth/pre-token-gen/test/scopes.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
computeScopesToSuppress,
|
||||||
|
entitledScopes,
|
||||||
|
ALL_TIER_SCOPES,
|
||||||
|
BASE_SCOPES,
|
||||||
|
OPS_READ,
|
||||||
|
OPS_TASKS,
|
||||||
|
FINANCE_READ,
|
||||||
|
FINANCE_ADMIN,
|
||||||
|
OPS_GMAIL,
|
||||||
|
OPS_CALENDAR,
|
||||||
|
} from '../src/scopes.js';
|
||||||
|
|
||||||
|
describe('entitledScopes', () => {
|
||||||
|
it('unions the scopes across a user’s groups', () => {
|
||||||
|
// Lauren: assistant + finance (design.md §2.3 worked example).
|
||||||
|
const got = entitledScopes(['sh-mcp-assistant', 'sh-mcp-finance']).sort();
|
||||||
|
expect(got).toEqual([OPS_CALENDAR, OPS_GMAIL, OPS_READ, OPS_TASKS, FINANCE_READ].sort());
|
||||||
|
// She does NOT get finance:admin.
|
||||||
|
expect(got).not.toContain(FINANCE_ADMIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown groups (a stray group grants nothing)', () => {
|
||||||
|
expect(entitledScopes(['sh-mcp-not-a-real-group'])).toEqual([]);
|
||||||
|
expect(entitledScopes([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin gets every tier scope', () => {
|
||||||
|
expect(entitledScopes(['sh-mcp-admin']).sort()).toEqual([...ALL_TIER_SCOPES].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeScopesToSuppress (suppress-only)', () => {
|
||||||
|
it('suppresses exactly the tier scopes the groups do not grant', () => {
|
||||||
|
// ops-only user: keeps ops:read, loses everything else.
|
||||||
|
const suppress = computeScopesToSuppress(['sh-mcp-ops'], true);
|
||||||
|
expect(suppress).toContain(OPS_TASKS);
|
||||||
|
expect(suppress).toContain(FINANCE_READ);
|
||||||
|
expect(suppress).toContain(FINANCE_ADMIN);
|
||||||
|
expect(suppress).not.toContain(OPS_READ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finance group keeps finance:read but never finance:admin', () => {
|
||||||
|
const suppress = computeScopesToSuppress(['sh-mcp-finance'], true);
|
||||||
|
expect(suppress).not.toContain(FINANCE_READ);
|
||||||
|
expect(suppress).not.toContain(OPS_READ);
|
||||||
|
expect(suppress).toContain(FINANCE_ADMIN);
|
||||||
|
expect(suppress).toContain(OPS_TASKS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin suppresses nothing', () => {
|
||||||
|
expect(computeScopesToSuppress(['sh-mcp-admin'], true)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: stale sync drops everyone to base ops:read regardless of groups', () => {
|
||||||
|
// Even a finance admin is reduced to ops:read when sync is stale.
|
||||||
|
const suppress = computeScopesToSuppress(['sh-mcp-admin'], false);
|
||||||
|
const kept = ALL_TIER_SCOPES.filter((s) => !suppress.includes(s));
|
||||||
|
expect(kept).toEqual([...BASE_SCOPES]);
|
||||||
|
expect(suppress).toContain(FINANCE_READ);
|
||||||
|
expect(suppress).toContain(FINANCE_ADMIN);
|
||||||
|
expect(suppress).toContain(OPS_TASKS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only ever returns scopes drawn from the issued tier-scope set (never invents one)', () => {
|
||||||
|
for (const groups of [[], ['sh-mcp-ops'], ['sh-mcp-finance'], ['sh-mcp-admin']]) {
|
||||||
|
for (const fresh of [true, false]) {
|
||||||
|
const suppress = computeScopesToSuppress(groups, fresh);
|
||||||
|
for (const s of suppress) expect(ALL_TIER_SCOPES).toContain(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
9
auth/pre-token-gen/tsconfig.json
Normal file
9
auth/pre-token-gen/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declarationDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
cdk.json
Normal file
7
cdk.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"app": "tsx infra/bin/app.ts",
|
||||||
|
"output": "cdk.out",
|
||||||
|
"context": {
|
||||||
|
"@aws-cdk/core:newStyleStackSynthesis": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,11 +25,13 @@ const config = [
|
||||||
|
|
||||||
// ── Source files (with project-based type checking) ─────────────────────────
|
// ── Source files (with project-based type checking) ─────────────────────────
|
||||||
{
|
{
|
||||||
files: ['packages/*/src/**/*.ts', 'servers/*/src/**/*.ts'],
|
files: ['packages/*/src/**/*.ts', 'servers/*/src/**/*.ts', 'auth/*/src/**/*.ts'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsparser,
|
parser: tsparser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: [
|
project: [
|
||||||
|
'./auth/pre-token-gen/tsconfig.json',
|
||||||
|
'./auth/group-sync/tsconfig.json',
|
||||||
'./packages/calendar/tsconfig.json',
|
'./packages/calendar/tsconfig.json',
|
||||||
'./packages/gmail/tsconfig.json',
|
'./packages/gmail/tsconfig.json',
|
||||||
'./packages/google-maps/tsconfig.json',
|
'./packages/google-maps/tsconfig.json',
|
||||||
|
|
@ -103,7 +105,13 @@ const config = [
|
||||||
// test/ dirs and cdk/ apps are excluded from the package/server tsconfigs, so
|
// test/ dirs and cdk/ apps are excluded from the package/server tsconfigs, so
|
||||||
// type-checked rules are disabled here to avoid "file not in project" errors.
|
// type-checked rules are disabled here to avoid "file not in project" errors.
|
||||||
{
|
{
|
||||||
files: ['packages/*/test/**/*.ts', 'servers/*/test/**/*.ts', 'servers/*/cdk/**/*.ts'],
|
files: [
|
||||||
|
'packages/*/test/**/*.ts',
|
||||||
|
'servers/*/test/**/*.ts',
|
||||||
|
'servers/*/cdk/**/*.ts',
|
||||||
|
'auth/*/test/**/*.ts',
|
||||||
|
'infra/**/*.ts',
|
||||||
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsparser,
|
parser: tsparser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|
|
||||||
19
infra/bin/app.ts
Normal file
19
infra/bin/app.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Root CDK app for sh-mcp. Phase 2a defines the `sh-mcp-auth` substrate stack;
|
||||||
|
* the per-tier `sh-mcp-ops` / `sh-mcp-finance` hosting stacks join here in 2b
|
||||||
|
* (replacing the synth-only per-server cdk stubs), so CI synthesizes one app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { App } from 'aws-cdk-lib';
|
||||||
|
import { ShMcpAuthStack } from '../lib/auth-stack.js';
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
new ShMcpAuthStack(app, 'ShMcpAuth', {
|
||||||
|
stackName: 'sh-mcp-auth',
|
||||||
|
env: { account: '328440206208', region: 'us-east-1' },
|
||||||
|
description:
|
||||||
|
'Sea Haven MCP auth substrate — Cognito broker, pre-token + group-sync Lambdas (design.md §2).',
|
||||||
|
});
|
||||||
|
|
||||||
|
app.synth();
|
||||||
347
infra/lib/auth-stack.ts
Normal file
347
infra/lib/auth-stack.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
/**
|
||||||
|
* sh-mcp-auth — the Cognito auth substrate (design.md §2; agentforce-plan §0.1).
|
||||||
|
*
|
||||||
|
* Stands up the OAuth2.1 broker the servers already validate against: a Google-
|
||||||
|
* federated Cognito user pool (ESSENTIALS feature plan — required for the V2
|
||||||
|
* pre-token trigger), per-tier app clients whose `AllowedOAuthScopes` are the
|
||||||
|
* PRIMARY trust-tier boundary, a SUPPRESS-ONLY pre-token Lambda, a 5-minute
|
||||||
|
* group-sync Lambda, and the sync-state + deny-list tables.
|
||||||
|
*
|
||||||
|
* Surface-agnostic: app-client callback URLs come from CDK context
|
||||||
|
* (`callbackUrls`), defaulting to a placeholder until the Agentforce-vs-Bolt
|
||||||
|
* surface is chosen. No API Gateway / WAF / server hosting here (Phase 2b).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
type StackProps,
|
||||||
|
Duration,
|
||||||
|
RemovalPolicy,
|
||||||
|
SecretValue,
|
||||||
|
aws_cognito as cognito,
|
||||||
|
aws_dynamodb as dynamodb,
|
||||||
|
aws_kms as kms,
|
||||||
|
aws_lambda as lambda,
|
||||||
|
aws_logs as logs,
|
||||||
|
aws_iam as iam,
|
||||||
|
aws_sns as sns,
|
||||||
|
aws_events as events,
|
||||||
|
aws_events_targets as targets,
|
||||||
|
aws_cloudwatch as cw,
|
||||||
|
aws_cloudwatch_actions as cwactions,
|
||||||
|
} from 'aws-cdk-lib';
|
||||||
|
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
|
||||||
|
import type { Construct } from 'constructs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
|
// Shared account CMKs (referenced by ARN, NOT fromLookup, so synth needs no AWS
|
||||||
|
// creds — memory: alarm-topic + sensitive-log-group CMK conventions).
|
||||||
|
const ACCOUNT = '328440206208';
|
||||||
|
const REGION = 'us-east-1';
|
||||||
|
const ALARM_KMS_ARN = `arn:aws:kms:${REGION}:${ACCOUNT}:key/abad16b5-5474-43b9-bbc4-89fc8a8a6ecf`; // alias/seahaven-alarm-topics
|
||||||
|
|
||||||
|
/** Resource-server-prefixed scope strings, kept in sync with the pre-token Lambda. */
|
||||||
|
const OPS_SCOPES = ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'];
|
||||||
|
const FINANCE_SCOPES = ['finance:read', 'finance:admin'];
|
||||||
|
|
||||||
|
export class ShMcpAuthStack extends Stack {
|
||||||
|
constructor(scope: Construct, id: string, props?: StackProps) {
|
||||||
|
super(scope, id, props);
|
||||||
|
|
||||||
|
const alarmKey = kms.Key.fromKeyArn(this, 'AlarmKey', ALARM_KMS_ARN);
|
||||||
|
// alias/seahaven-logs CMK ARN is supplied at deploy via context (-c logsKmsArn=...)
|
||||||
|
// to avoid hardcoding/guessing the key id; these log groups carry no secrets
|
||||||
|
// (scope decisions + counts only), so absent context we use AWS-managed encryption.
|
||||||
|
const logsKmsArn = this.node.tryGetContext('logsKmsArn') as string | undefined;
|
||||||
|
const logsKey = logsKmsArn ? kms.Key.fromKeyArn(this, 'LogsKey', logsKmsArn) : undefined;
|
||||||
|
|
||||||
|
// --- Alarm topic (CMK alias/seahaven-alarm-topics; never alias/aws/sns) ---
|
||||||
|
const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
|
||||||
|
topicName: 'sh-mcp-alarms',
|
||||||
|
masterKey: alarmKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- DynamoDB: sync-state freshness marker + deny-list (hard revocation) ---
|
||||||
|
const syncState = new dynamodb.Table(this, 'SyncState', {
|
||||||
|
tableName: 'sh-mcp-sync-state',
|
||||||
|
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
|
||||||
|
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||||
|
encryption: dynamodb.TableEncryption.AWS_MANAGED,
|
||||||
|
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
||||||
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
|
});
|
||||||
|
// Stable logical IDs so a future construct-path refactor never replaces (and
|
||||||
|
// drops) these tables (handbook: never remove overrideLogicalId).
|
||||||
|
(syncState.node.defaultChild as dynamodb.CfnTable).overrideLogicalId('SyncStateTable');
|
||||||
|
|
||||||
|
const denyList = new dynamodb.Table(this, 'DenyList', {
|
||||||
|
tableName: 'sh-mcp-deny-list',
|
||||||
|
partitionKey: { name: 'sub', type: dynamodb.AttributeType.STRING },
|
||||||
|
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||||
|
encryption: dynamodb.TableEncryption.AWS_MANAGED,
|
||||||
|
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
|
||||||
|
timeToLiveAttribute: 'expiresAt', // epoch seconds; auto-expires a revocation
|
||||||
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
|
});
|
||||||
|
(denyList.node.defaultChild as dynamodb.CfnTable).overrideLogicalId('DenyListTable');
|
||||||
|
|
||||||
|
// --- Lambdas (Node, arm64, explicit 60-day CMK-encrypted log groups) ---
|
||||||
|
const preTokenLogs = new logs.LogGroup(this, 'PreTokenLogs', {
|
||||||
|
logGroupName: '/aws/lambda/sh-mcp-pre-token-gen',
|
||||||
|
retention: logs.RetentionDays.TWO_MONTHS,
|
||||||
|
encryptionKey: logsKey,
|
||||||
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
|
});
|
||||||
|
const preTokenFn = new NodejsFunction(this, 'PreTokenFn', {
|
||||||
|
functionName: 'sh-mcp-pre-token-gen',
|
||||||
|
runtime: lambda.Runtime.NODEJS_22_X,
|
||||||
|
architecture: lambda.Architecture.ARM_64,
|
||||||
|
entry: path.join(repoRoot, 'auth', 'pre-token-gen', 'src', 'index.ts'),
|
||||||
|
handler: 'handler',
|
||||||
|
timeout: Duration.seconds(5),
|
||||||
|
memorySize: 256,
|
||||||
|
logGroup: preTokenLogs,
|
||||||
|
environment: {
|
||||||
|
SYNC_STATE_TABLE: syncState.tableName,
|
||||||
|
DENY_LIST_TABLE: denyList.tableName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Least privilege: read-only on the sync-state marker and the deny-list
|
||||||
|
// (consulted at every mint for hard revocation), nothing else.
|
||||||
|
syncState.grantReadData(preTokenFn);
|
||||||
|
denyList.grantReadData(preTokenFn);
|
||||||
|
|
||||||
|
const groupSyncLogs = new logs.LogGroup(this, 'GroupSyncLogs', {
|
||||||
|
logGroupName: '/aws/lambda/sh-mcp-group-sync',
|
||||||
|
retention: logs.RetentionDays.TWO_MONTHS,
|
||||||
|
encryptionKey: logsKey,
|
||||||
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
|
});
|
||||||
|
const groupSyncFn = new NodejsFunction(this, 'GroupSyncFn', {
|
||||||
|
functionName: 'sh-mcp-group-sync',
|
||||||
|
runtime: lambda.Runtime.NODEJS_22_X,
|
||||||
|
architecture: lambda.Architecture.ARM_64,
|
||||||
|
entry: path.join(repoRoot, 'auth', 'group-sync', 'src', 'index.ts'),
|
||||||
|
handler: 'handler',
|
||||||
|
timeout: Duration.seconds(60),
|
||||||
|
memorySize: 256,
|
||||||
|
logGroup: groupSyncLogs,
|
||||||
|
environment: {
|
||||||
|
USER_POOL_ID: '', // set below once the pool exists (avoids a cycle)
|
||||||
|
SYNC_STATE_TABLE: syncState.tableName,
|
||||||
|
GOOGLE_SA_SECRET_ARN: `arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:sh-mcp/google-directory-sa`,
|
||||||
|
WORKSPACE_DOMAIN: 'seahavenind.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
syncState.grantWriteData(groupSyncFn);
|
||||||
|
// Scoped Secrets Manager read for ONLY the Google service-account secret.
|
||||||
|
groupSyncFn.addToRolePolicy(
|
||||||
|
new iam.PolicyStatement({
|
||||||
|
actions: ['secretsmanager:GetSecretValue'],
|
||||||
|
resources: [
|
||||||
|
`arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:sh-mcp/google-directory-sa-*`,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Cognito user pool (ESSENTIALS — required for the V2 pre-token trigger) ---
|
||||||
|
const userPool = new cognito.UserPool(this, 'UserPool', {
|
||||||
|
userPoolName: 'sh-mcp',
|
||||||
|
featurePlan: cognito.FeaturePlan.ESSENTIALS,
|
||||||
|
selfSignUpEnabled: false,
|
||||||
|
signInAliases: { email: true },
|
||||||
|
standardAttributes: { email: { required: true, mutable: true } },
|
||||||
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Google as an external OIDC IdP. client_id/secret resolve from Secrets
|
||||||
|
// Manager at deploy via a CFN dynamic reference (never inlined in the template).
|
||||||
|
const googleIdp = new cognito.CfnUserPoolIdentityProvider(this, 'GoogleIdp', {
|
||||||
|
userPoolId: userPool.userPoolId,
|
||||||
|
providerName: 'Google',
|
||||||
|
providerType: 'Google',
|
||||||
|
attributeMapping: { email: 'email', username: 'sub' },
|
||||||
|
providerDetails: {
|
||||||
|
client_id: SecretValue.secretsManager('sh-mcp/google-oidc', {
|
||||||
|
jsonField: 'client_id',
|
||||||
|
}).toString(),
|
||||||
|
client_secret: SecretValue.secretsManager('sh-mcp/google-oidc', {
|
||||||
|
jsonField: 'client_secret',
|
||||||
|
}).toString(),
|
||||||
|
authorize_scopes: 'openid email profile',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resource servers carry the custom scopes the access tokens are prefixed with.
|
||||||
|
const opsRs = userPool.addResourceServer('OpsResourceServer', {
|
||||||
|
identifier: 'sh-mcp-ops',
|
||||||
|
scopes: OPS_SCOPES.map(
|
||||||
|
(s) => new cognito.ResourceServerScope({ scopeName: s, scopeDescription: s }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const financeRs = userPool.addResourceServer('FinanceResourceServer', {
|
||||||
|
identifier: 'sh-mcp-finance',
|
||||||
|
scopes: FINANCE_SCOPES.map(
|
||||||
|
(s) => new cognito.ResourceServerScope({ scopeName: s, scopeDescription: s }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rsScope = (rs: cognito.IUserPoolResourceServer, name: string): cognito.OAuthScope =>
|
||||||
|
cognito.OAuthScope.resourceServer(
|
||||||
|
rs,
|
||||||
|
new cognito.ResourceServerScope({ scopeName: name, scopeDescription: name }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callbackUrls = (this.node.tryGetContext('callbackUrls') as string[] | undefined) ?? [
|
||||||
|
'https://placeholder.seahavenind.com/oauth/callback', // surface TBD (Agentforce vs Bolt)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Per-tier app clients. AllowedOAuthScopes IS the trust-tier ceiling: finance
|
||||||
|
// is finance:read ONLY; exec is ops + gmail/calendar with NO finance; ops is
|
||||||
|
// read+tasks. A client physically cannot request a scope outside its tier.
|
||||||
|
const mkClient = (
|
||||||
|
cid: string,
|
||||||
|
scopes: cognito.OAuthScope[],
|
||||||
|
accessTokenValidity: Duration,
|
||||||
|
// Per-tier refresh window. The refresh token is the real persistence horizon:
|
||||||
|
// a 15-min access TTL is meaningless if a stolen refresh token re-mints for
|
||||||
|
// 30 days, so the sensitive finance tier gets a short window of its own.
|
||||||
|
refreshTokenValidity: Duration,
|
||||||
|
): cognito.UserPoolClient =>
|
||||||
|
userPool.addClient(cid, {
|
||||||
|
userPoolClientName: cid,
|
||||||
|
generateSecret: true,
|
||||||
|
oAuth: {
|
||||||
|
flows: { authorizationCodeGrant: true },
|
||||||
|
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, ...scopes],
|
||||||
|
callbackUrls,
|
||||||
|
},
|
||||||
|
supportedIdentityProviders: [cognito.UserPoolClientIdentityProvider.GOOGLE],
|
||||||
|
accessTokenValidity,
|
||||||
|
idTokenValidity: accessTokenValidity,
|
||||||
|
refreshTokenValidity,
|
||||||
|
enableTokenRevocation: true,
|
||||||
|
preventUserExistenceErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opsClient = mkClient(
|
||||||
|
'sh-agentforce-ops',
|
||||||
|
[rsScope(opsRs, 'ops:read'), rsScope(opsRs, 'ops:tasks')],
|
||||||
|
Duration.minutes(60),
|
||||||
|
Duration.days(30),
|
||||||
|
);
|
||||||
|
const execClient = mkClient(
|
||||||
|
'sh-agentforce-exec',
|
||||||
|
[
|
||||||
|
rsScope(opsRs, 'ops:read'),
|
||||||
|
rsScope(opsRs, 'ops:tasks'),
|
||||||
|
rsScope(opsRs, 'gmail:self'),
|
||||||
|
rsScope(opsRs, 'calendar:self'),
|
||||||
|
],
|
||||||
|
Duration.minutes(60),
|
||||||
|
Duration.days(30),
|
||||||
|
);
|
||||||
|
const financeClient = mkClient(
|
||||||
|
'sh-agentforce-finance',
|
||||||
|
[rsScope(financeRs, 'finance:read')], // finance:read ONLY — 15-min access TTL
|
||||||
|
Duration.minutes(15),
|
||||||
|
Duration.hours(8), // short refresh horizon: a stolen finance refresh token dies in 8h, not 30d
|
||||||
|
);
|
||||||
|
for (const c of [opsClient, execClient, financeClient]) c.node.addDependency(googleIdp);
|
||||||
|
|
||||||
|
// Cognito groups the group-sync Lambda reconciles from Google Groups.
|
||||||
|
for (const g of ['sh-mcp-ops', 'sh-mcp-assistant', 'sh-mcp-finance', 'sh-mcp-admin']) {
|
||||||
|
new cognito.CfnUserPoolGroup(this, `Group-${g}`, {
|
||||||
|
userPoolId: userPool.userPoolId,
|
||||||
|
groupName: g,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the group-sync Lambda's pool id now that the pool exists, and grant
|
||||||
|
// least-privilege Cognito group-management on THIS pool only.
|
||||||
|
groupSyncFn.addEnvironment('USER_POOL_ID', userPool.userPoolId);
|
||||||
|
groupSyncFn.addToRolePolicy(
|
||||||
|
new iam.PolicyStatement({
|
||||||
|
actions: [
|
||||||
|
'cognito-idp:CreateGroup',
|
||||||
|
'cognito-idp:ListUsersInGroup',
|
||||||
|
'cognito-idp:ListUsers',
|
||||||
|
'cognito-idp:AdminAddUserToGroup',
|
||||||
|
'cognito-idp:AdminRemoveUserFromGroup',
|
||||||
|
],
|
||||||
|
resources: [userPool.userPoolArn],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach the SUPPRESS-ONLY pre-token Lambda as a V2 trigger (escape hatch:
|
||||||
|
// the L2 addTrigger does not expose LambdaVersion, and V2 is required for
|
||||||
|
// scope override). Grant Cognito permission to invoke it.
|
||||||
|
const cfnPool = userPool.node.defaultChild as cognito.CfnUserPool;
|
||||||
|
cfnPool.lambdaConfig = {
|
||||||
|
preTokenGenerationConfig: {
|
||||||
|
lambdaArn: preTokenFn.functionArn,
|
||||||
|
lambdaVersion: 'V2_0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
preTokenFn.addPermission('CognitoInvoke', {
|
||||||
|
principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
|
||||||
|
sourceArn: userPool.userPoolArn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Group-sync schedule: every 5 minutes ---
|
||||||
|
new events.Rule(this, 'GroupSyncSchedule', {
|
||||||
|
ruleName: 'sh-mcp-group-sync',
|
||||||
|
schedule: events.Schedule.rate(Duration.minutes(5)),
|
||||||
|
targets: [new targets.LambdaFunction(groupSyncFn)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Alarms (ALARM-state actions only, CMK topic) ---
|
||||||
|
const onAlarm = new cwactions.SnsAction(alarmTopic);
|
||||||
|
|
||||||
|
// Group-sync failure (any error in a 15-min window).
|
||||||
|
const groupSyncErrors = groupSyncFn
|
||||||
|
.metricErrors({ period: Duration.minutes(15), statistic: 'Sum' })
|
||||||
|
.createAlarm(this, 'GroupSyncErrorsAlarm', {
|
||||||
|
alarmName: 'sh-mcp-group-sync-errors',
|
||||||
|
threshold: 1,
|
||||||
|
evaluationPeriods: 1,
|
||||||
|
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
||||||
|
treatMissingData: cw.TreatMissingData.NOT_BREACHING,
|
||||||
|
});
|
||||||
|
groupSyncErrors.addAlarmAction(onAlarm);
|
||||||
|
|
||||||
|
// Group-sync stopped running entirely (two-alarm pattern for the 5-min job).
|
||||||
|
const groupSyncMissing = groupSyncFn
|
||||||
|
.metricInvocations({ period: Duration.minutes(15), statistic: 'Sum' })
|
||||||
|
.createAlarm(this, 'GroupSyncMissingAlarm', {
|
||||||
|
alarmName: 'sh-mcp-group-sync-missing',
|
||||||
|
threshold: 1,
|
||||||
|
evaluationPeriods: 1,
|
||||||
|
comparisonOperator: cw.ComparisonOperator.LESS_THAN_THRESHOLD,
|
||||||
|
treatMissingData: cw.TreatMissingData.BREACHING,
|
||||||
|
});
|
||||||
|
groupSyncMissing.addAlarmAction(onAlarm);
|
||||||
|
|
||||||
|
// Pre-token Lambda error rate (a bug here blocks or mis-scopes token issuance).
|
||||||
|
const preTokenErrors = preTokenFn
|
||||||
|
.metricErrors({ period: Duration.minutes(5), statistic: 'Sum' })
|
||||||
|
.createAlarm(this, 'PreTokenErrorsAlarm', {
|
||||||
|
alarmName: 'sh-mcp-pre-token-errors',
|
||||||
|
threshold: 1,
|
||||||
|
evaluationPeriods: 1,
|
||||||
|
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
||||||
|
treatMissingData: cw.TreatMissingData.NOT_BREACHING,
|
||||||
|
});
|
||||||
|
preTokenErrors.addAlarmAction(onAlarm);
|
||||||
|
|
||||||
|
// Expose ids for the servers' config (consumed in Phase 2b wiring).
|
||||||
|
this.exportValue(userPool.userPoolId, { name: 'sh-mcp-user-pool-id' });
|
||||||
|
this.exportValue(opsClient.userPoolClientId, { name: 'sh-mcp-ops-client-id' });
|
||||||
|
this.exportValue(execClient.userPoolClientId, { name: 'sh-mcp-exec-client-id' });
|
||||||
|
this.exportValue(financeClient.userPoolClientId, { name: 'sh-mcp-finance-client-id' });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
infra/package.json
Normal file
25
infra/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "@sh-mcp/infra",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Sea Haven MCP CDK app — auth substrate stack (Phase 2a)",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"synth": "cdk synth >/dev/null",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"aws-cdk": "2.1128.1",
|
||||||
|
"aws-cdk-lib": "2.260.0",
|
||||||
|
"constructs": "10.6.0",
|
||||||
|
"tsx": "4.22.4",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
196
infra/test/auth-stack.test.ts
Normal file
196
infra/test/auth-stack.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { App } from 'aws-cdk-lib';
|
||||||
|
import { Template, Match } from 'aws-cdk-lib/assertions';
|
||||||
|
|
||||||
|
import { ShMcpAuthStack } from '../lib/auth-stack.js';
|
||||||
|
|
||||||
|
let template: Template;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const app = new App();
|
||||||
|
const stack = new ShMcpAuthStack(app, 'TestAuth', {
|
||||||
|
stackName: 'sh-mcp-auth',
|
||||||
|
env: { account: '328440206208', region: 'us-east-1' },
|
||||||
|
});
|
||||||
|
template = Template.fromStack(stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cognito user pool', () => {
|
||||||
|
it('uses the ESSENTIALS feature plan (required for the V2 pre-token trigger)', () => {
|
||||||
|
template.hasResourceProperties('AWS::Cognito::UserPool', {
|
||||||
|
UserPoolTier: 'ESSENTIALS',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches the pre-token Lambda as a V2_0 trigger', () => {
|
||||||
|
template.hasResourceProperties('AWS::Cognito::UserPool', {
|
||||||
|
LambdaConfig: {
|
||||||
|
PreTokenGenerationConfig: Match.objectLike({ LambdaVersion: 'V2_0' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines the four managed groups', () => {
|
||||||
|
for (const g of ['sh-mcp-ops', 'sh-mcp-assistant', 'sh-mcp-finance', 'sh-mcp-admin']) {
|
||||||
|
template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { GroupName: g });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('app clients — AllowedOAuthScopes is the trust-tier boundary', () => {
|
||||||
|
function clientScopes(name: string): string[] {
|
||||||
|
const clients = template.findResources('AWS::Cognito::UserPoolClient');
|
||||||
|
const entry = Object.values(clients).find((c) => c.Properties?.ClientName === name);
|
||||||
|
expect(entry, `client ${name} exists`).toBeDefined();
|
||||||
|
return (entry!.Properties.AllowedOAuthScopes as unknown[]).map((s) =>
|
||||||
|
typeof s === 'string' ? s : JSON.stringify(s),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('finance client carries finance:read ONLY — no ops/gmail/finance:admin', () => {
|
||||||
|
const joined = JSON.stringify(clientScopes('sh-agentforce-finance'));
|
||||||
|
expect(joined).toContain('finance:read');
|
||||||
|
expect(joined).not.toContain('finance:admin');
|
||||||
|
expect(joined).not.toContain('ops:read');
|
||||||
|
expect(joined).not.toContain('gmail:self');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exec client has ops + gmail/calendar but NEVER finance (lethal-trifecta separation)', () => {
|
||||||
|
const joined = JSON.stringify(clientScopes('sh-agentforce-exec'));
|
||||||
|
expect(joined).toContain('ops:read');
|
||||||
|
expect(joined).toContain('gmail:self');
|
||||||
|
expect(joined).toContain('calendar:self');
|
||||||
|
expect(joined).not.toContain('finance:read');
|
||||||
|
expect(joined).not.toContain('finance:admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ops client has ops:read + ops:tasks, no finance/gmail', () => {
|
||||||
|
const joined = JSON.stringify(clientScopes('sh-agentforce-ops'));
|
||||||
|
expect(joined).toContain('ops:read');
|
||||||
|
expect(joined).toContain('ops:tasks');
|
||||||
|
expect(joined).not.toContain('finance');
|
||||||
|
expect(joined).not.toContain('gmail:self');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finance client has a 15-minute access token TTL', () => {
|
||||||
|
const clients = template.findResources('AWS::Cognito::UserPoolClient');
|
||||||
|
const fin = Object.values(clients).find(
|
||||||
|
(c) => c.Properties?.ClientName === 'sh-agentforce-finance',
|
||||||
|
);
|
||||||
|
expect(fin!.Properties.AccessTokenValidity).toBe(15);
|
||||||
|
expect(fin!.Properties.TokenValidityUnits.AccessToken).toBe('minutes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finance client has a short refresh window (≤24h, not the 30-day default)', () => {
|
||||||
|
const toMinutes: Record<string, number> = { minutes: 1, hours: 60, days: 1440 };
|
||||||
|
const refreshMinutes = (name: string): number => {
|
||||||
|
const clients = template.findResources('AWS::Cognito::UserPoolClient');
|
||||||
|
const c = Object.values(clients).find((x) => x.Properties?.ClientName === name)!;
|
||||||
|
return (
|
||||||
|
c.Properties.RefreshTokenValidity * toMinutes[c.Properties.TokenValidityUnits.RefreshToken]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// A stolen finance refresh token must die in hours; ops/exec keep 30 days.
|
||||||
|
expect(refreshMinutes('sh-agentforce-finance')).toBeLessThanOrEqual(24 * 60);
|
||||||
|
expect(refreshMinutes('sh-agentforce-ops')).toBe(30 * 1440);
|
||||||
|
expect(refreshMinutes('sh-agentforce-exec')).toBe(30 * 1440);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DynamoDB tables', () => {
|
||||||
|
it('pins stable logical IDs (overrideLogicalId) so refactors cannot replace them', () => {
|
||||||
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
||||||
|
expect(Object.keys(tables)).toEqual(
|
||||||
|
expect.arrayContaining(['SyncStateTable', 'DenyListTable']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deny-list has a TTL attribute for auto-expiring revocations', () => {
|
||||||
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
||||||
|
TableName: 'sh-mcp-deny-list',
|
||||||
|
TimeToLiveSpecification: { AttributeName: 'expiresAt', Enabled: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both tables RETAIN on stack delete', () => {
|
||||||
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
||||||
|
for (const t of Object.values(tables)) expect(t.DeletionPolicy).toBe('Retain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Lambdas', () => {
|
||||||
|
it('run on arm64 with explicit 60-day log retention', () => {
|
||||||
|
template.allResourcesProperties('AWS::Lambda::Function', {
|
||||||
|
Architectures: ['arm64'],
|
||||||
|
});
|
||||||
|
template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-token Lambda is wired to the deny-list (env var) for hard revocation', () => {
|
||||||
|
const fns = template.findResources('AWS::Lambda::Function');
|
||||||
|
const pre = Object.values(fns).find(
|
||||||
|
(f) => f.Properties?.FunctionName === 'sh-mcp-pre-token-gen',
|
||||||
|
);
|
||||||
|
expect(pre!.Properties.Environment.Variables.DENY_LIST_TABLE).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IAM least privilege', () => {
|
||||||
|
it('no Lambda policy grants a wildcard action or wildcard resource', () => {
|
||||||
|
const policies = template.findResources('AWS::IAM::Policy');
|
||||||
|
for (const p of Object.values(policies)) {
|
||||||
|
for (const stmt of p.Properties.PolicyDocument.Statement as {
|
||||||
|
Effect: string;
|
||||||
|
Action: unknown;
|
||||||
|
Resource: unknown;
|
||||||
|
}[]) {
|
||||||
|
if (stmt.Effect !== 'Allow') continue;
|
||||||
|
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
||||||
|
for (const a of actions) expect(a, 'no wildcard action').not.toBe('*');
|
||||||
|
const resources = Array.isArray(stmt.Resource) ? stmt.Resource : [stmt.Resource];
|
||||||
|
for (const r of resources) expect(r, 'no bare wildcard resource').not.toBe('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Google SA secret grant is scoped to that one secret', () => {
|
||||||
|
template.hasResourceProperties('AWS::IAM::Policy', {
|
||||||
|
PolicyDocument: {
|
||||||
|
Statement: Match.arrayWith([
|
||||||
|
Match.objectLike({
|
||||||
|
Action: 'secretsmanager:GetSecretValue',
|
||||||
|
Resource: Match.stringLikeRegexp('secret:sh-mcp/google-directory-sa'),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('alarms', () => {
|
||||||
|
it('every alarm has an alarm action (CMK-encrypted SNS topic)', () => {
|
||||||
|
const alarms = template.findResources('AWS::CloudWatch::Alarm');
|
||||||
|
expect(Object.keys(alarms).length).toBeGreaterThanOrEqual(3);
|
||||||
|
for (const a of Object.values(alarms)) {
|
||||||
|
expect(Array.isArray(a.Properties.AlarmActions)).toBe(true);
|
||||||
|
expect(a.Properties.AlarmActions.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// ALARM-state actions only — never OKActions.
|
||||||
|
expect(a.Properties.OKActions).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the alarm SNS topic is encrypted with a KMS key (not unencrypted)', () => {
|
||||||
|
template.hasResourceProperties('AWS::SNS::Topic', {
|
||||||
|
TopicName: 'sh-mcp-alarms',
|
||||||
|
KmsMasterKeyId: Match.anyValue(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group-sync schedule', () => {
|
||||||
|
it('runs every 5 minutes', () => {
|
||||||
|
template.hasResourceProperties('AWS::Events::Rule', {
|
||||||
|
ScheduleExpression: 'rate(5 minutes)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
infra/tsconfig.json
Normal file
14
infra/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"noEmit": true,
|
||||||
|
// CDK assertion tests inspect dynamically-typed CFN template JSON
|
||||||
|
// (Template.findResources) where bracket access would be noise.
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["bin", "lib", "test"]
|
||||||
|
}
|
||||||
705
package-lock.json
generated
705
package-lock.json
generated
|
|
@ -32,6 +32,41 @@
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"auth/group-sync": {
|
||||||
|
"name": "@sh-mcp/auth-group-sync",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-cognito-identity-provider": "^3.700.0",
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.700.0",
|
||||||
|
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.700.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "3.0.2",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth/pre-token-gen": {
|
||||||
|
"name": "@sh-mcp/auth-pre-token-gen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.700.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.700.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "3.0.2",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
|
|
@ -99,6 +134,508 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-crypto/crc32": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/util": "^5.2.0",
|
||||||
|
"@aws-sdk/types": "^3.222.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-crypto/sha256-browser": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-js": "^5.2.0",
|
||||||
|
"@aws-crypto/supports-web-crypto": "^5.2.0",
|
||||||
|
"@aws-crypto/util": "^5.2.0",
|
||||||
|
"@aws-sdk/types": "^3.222.0",
|
||||||
|
"@aws-sdk/util-locate-window": "^3.0.0",
|
||||||
|
"@smithy/util-utf8": "^2.0.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-crypto/sha256-js": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/util": "^5.2.0",
|
||||||
|
"@aws-sdk/types": "^3.222.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-crypto/supports-web-crypto": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-crypto/util": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.222.0",
|
||||||
|
"@smithy/util-utf8": "^2.0.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/client-cognito-identity-provider": {
|
||||||
|
"version": "3.1075.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1075.0.tgz",
|
||||||
|
"integrity": "sha512-yW358bkQ0veCcJdNWT1MhvRl1lABuBf/9LK3qmagjUFSaNUtUCO/DQTekSDwtcfUGIYcZXXMasjJW0pge6AtXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.972.58",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/fetch-http-handler": "^5.4.6",
|
||||||
|
"@smithy/node-http-handler": "^4.7.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/client-dynamodb": {
|
||||||
|
"version": "3.1075.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1075.0.tgz",
|
||||||
|
"integrity": "sha512-3KlTXh6U2nDqL+epT1XdxnszLtCEAvoeO7phI4UGiQeKMiibcyBsJvSlyEj1tBoNOsbdcmp1QLM8za415sDzzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.972.58",
|
||||||
|
"@aws-sdk/dynamodb-codec": "^3.973.23",
|
||||||
|
"@aws-sdk/middleware-endpoint-discovery": "^3.972.19",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/fetch-http-handler": "^5.4.6",
|
||||||
|
"@smithy/node-http-handler": "^4.7.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/client-secrets-manager": {
|
||||||
|
"version": "3.1075.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1075.0.tgz",
|
||||||
|
"integrity": "sha512-5cLjSmrgzohO2q4gMrb7oHq58W+gR/qul7yHSRZ1fZv7aYtONvq23dKr+E7i0KBid4zkjGpjw8VJ7fNdQi9YUQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.972.58",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/fetch-http-handler": "^5.4.6",
|
||||||
|
"@smithy/node-http-handler": "^4.7.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/core": {
|
||||||
|
"version": "3.974.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz",
|
||||||
|
"integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@aws-sdk/xml-builder": "^3.972.31",
|
||||||
|
"@aws/lambda-invoke-store": "^0.2.2",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/signature-v4": "^5.4.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"bowser": "^2.11.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-env": {
|
||||||
|
"version": "3.972.49",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz",
|
||||||
|
"integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-http": {
|
||||||
|
"version": "3.972.51",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz",
|
||||||
|
"integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/fetch-http-handler": "^5.4.6",
|
||||||
|
"@smithy/node-http-handler": "^4.7.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||||
|
"version": "3.972.56",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz",
|
||||||
|
"integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/credential-provider-env": "^3.972.49",
|
||||||
|
"@aws-sdk/credential-provider-http": "^3.972.51",
|
||||||
|
"@aws-sdk/credential-provider-login": "^3.972.55",
|
||||||
|
"@aws-sdk/credential-provider-process": "^3.972.49",
|
||||||
|
"@aws-sdk/credential-provider-sso": "^3.972.55",
|
||||||
|
"@aws-sdk/credential-provider-web-identity": "^3.972.55",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/credential-provider-imds": "^4.3.7",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-login": {
|
||||||
|
"version": "3.972.55",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz",
|
||||||
|
"integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-node": {
|
||||||
|
"version": "3.972.58",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz",
|
||||||
|
"integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/credential-provider-env": "^3.972.49",
|
||||||
|
"@aws-sdk/credential-provider-http": "^3.972.51",
|
||||||
|
"@aws-sdk/credential-provider-ini": "^3.972.56",
|
||||||
|
"@aws-sdk/credential-provider-process": "^3.972.49",
|
||||||
|
"@aws-sdk/credential-provider-sso": "^3.972.55",
|
||||||
|
"@aws-sdk/credential-provider-web-identity": "^3.972.55",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/credential-provider-imds": "^4.3.7",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-process": {
|
||||||
|
"version": "3.972.49",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz",
|
||||||
|
"integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||||
|
"version": "3.972.55",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz",
|
||||||
|
"integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.23",
|
||||||
|
"@aws-sdk/token-providers": "3.1074.0",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||||
|
"version": "3.972.55",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz",
|
||||||
|
"integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/dynamodb-codec": {
|
||||||
|
"version": "3.973.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.23.tgz",
|
||||||
|
"integrity": "sha512-GFfrjNXw+QteWbum8jD22G3T0FD/XiJEGp6r1CoqndMc6pxyQFoQ8nqTuNn1rbqYwqv4iCTl7GYoB9H17REKzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/endpoint-cache": {
|
||||||
|
"version": "3.972.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.8.tgz",
|
||||||
|
"integrity": "sha512-bBmkG0Dnhfq0/T4Z0PpUr7HkncBVaWvvCbvafeaUM+yC9wa8GGjLJmonq0QL17REB9WivgGeYgWQ5A80Uw5UnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"mnemonist": "0.38.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/lib-dynamodb": {
|
||||||
|
"version": "3.1075.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1075.0.tgz",
|
||||||
|
"integrity": "sha512-3OxmMDgk8wvK6QOaVrQuOq9t6xlgS5047aDPxAZxTSwGBcEMc6FtiamtoK3kfClfOt7k3t+NWIo0bIfhWQ4Pzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/util-dynamodb": "^3.996.5",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.1075.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/middleware-endpoint-discovery": {
|
||||||
|
"version": "3.972.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.19.tgz",
|
||||||
|
"integrity": "sha512-FMgyzUq3Jh+ONRYxryBRNdBd+FUX8PwRl07ccQknNdoms6KCeAEusCkl6whqpDrPQ6OH0ddeSifKyqYSs2DLIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/endpoint-cache": "^3.972.8",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/nested-clients": {
|
||||||
|
"version": "3.997.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz",
|
||||||
|
"integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/fetch-http-handler": "^5.4.6",
|
||||||
|
"@smithy/node-http-handler": "^4.7.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
|
"version": "3.996.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz",
|
||||||
|
"integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/signature-v4": "^5.4.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/token-providers": {
|
||||||
|
"version": "3.1074.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz",
|
||||||
|
"integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.974.23",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.23",
|
||||||
|
"@aws-sdk/types": "^3.973.13",
|
||||||
|
"@smithy/core": "^3.24.6",
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/types": {
|
||||||
|
"version": "3.973.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz",
|
||||||
|
"integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/util-dynamodb": {
|
||||||
|
"version": "3.996.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.5.tgz",
|
||||||
|
"integrity": "sha512-m9bdmYq3WtbMHAKGALw9XWiMBfKu5T8ukgdJT7Mc/d2oOwDGNFmhsnnkQ18xomoXo/ZHxAuIDi3Y6slsblW1Mg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.1069.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/util-locate-window": {
|
||||||
|
"version": "3.965.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz",
|
||||||
|
"integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
|
"version": "3.972.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz",
|
||||||
|
"integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.14.3",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
|
@ -1439,6 +1976,14 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@sh-mcp/auth-group-sync": {
|
||||||
|
"resolved": "auth/group-sync",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@sh-mcp/auth-pre-token-gen": {
|
||||||
|
"resolved": "auth/pre-token-gen",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@sh-mcp/calendar": {
|
"node_modules/@sh-mcp/calendar": {
|
||||||
"resolved": "packages/calendar",
|
"resolved": "packages/calendar",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -1487,6 +2032,135 @@
|
||||||
"resolved": "packages/tasks",
|
"resolved": "packages/tasks",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@smithy/core": {
|
||||||
|
"version": "3.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz",
|
||||||
|
"integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-crypto/crc32": "5.2.0",
|
||||||
|
"@smithy/types": "^4.15.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/credential-provider-imds": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.26.0",
|
||||||
|
"@smithy/types": "^4.15.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/fetch-http-handler": {
|
||||||
|
"version": "5.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz",
|
||||||
|
"integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.26.0",
|
||||||
|
"@smithy/types": "^4.15.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/is-array-buffer": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/node-http-handler": {
|
||||||
|
"version": "4.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz",
|
||||||
|
"integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.26.0",
|
||||||
|
"@smithy/types": "^4.15.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/signature-v4": {
|
||||||
|
"version": "5.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz",
|
||||||
|
"integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.26.0",
|
||||||
|
"@smithy/types": "^4.15.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/types": {
|
||||||
|
"version": "4.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz",
|
||||||
|
"integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/util-buffer-from": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/is-array-buffer": "^2.2.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/util-utf8": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/util-buffer-from": "^2.2.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
|
@ -2674,6 +3348,13 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bowser": {
|
||||||
|
"version": "2.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||||
|
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
|
|
@ -4438,6 +5119,16 @@
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mnemonist": {
|
||||||
|
"version": "0.38.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz",
|
||||||
|
"integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"obliterator": "^1.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -4500,6 +5191,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obliterator": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
|
@ -5536,6 +6234,13 @@
|
||||||
"typescript": ">=4.2.0"
|
"typescript": ">=4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.4",
|
"version": "4.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,12 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./servers/sh-mcp-finance"
|
"path": "./servers/sh-mcp-finance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./auth/pre-token-gen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./auth/group-sync"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue