mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 03:03:15 +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:
|
||||
node-version: '24'
|
||||
enable-qemu: true
|
||||
# Phase 0b ships no CDK app (no cdk.json / stacks); infra lands in Phase 1.
|
||||
run-cdk-synth: false
|
||||
# Phase 2a adds the root cdk.json → infra/ app (sh-mcp-auth). The reusable
|
||||
# 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
|
||||
|
|
|
|||
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
|
||||
deprecated completely; the conversational surface becomes a configurable Slack task agent.
|
||||
|
||||
> **Status: DESIGN / PLANNING. Not built. No stack deployed.**
|
||||
> The full design, auth architecture, scope matrix, and build plan live in
|
||||
> [`docs/design.md`](docs/design.md). Read it before writing any code.
|
||||
> **Status: BUILDING. Platform + auth substrate built; nothing deployed to AWS yet.**
|
||||
> Phase 0b (monorepo + `@sh-mcp/shared` core), Phase 1 (runnable `sh-mcp-ops` /
|
||||
> `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)
|
||||
|
||||
|
|
|
|||
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) ─────────────────────────
|
||||
{
|
||||
files: ['packages/*/src/**/*.ts', 'servers/*/src/**/*.ts'],
|
||||
files: ['packages/*/src/**/*.ts', 'servers/*/src/**/*.ts', 'auth/*/src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
project: [
|
||||
'./auth/pre-token-gen/tsconfig.json',
|
||||
'./auth/group-sync/tsconfig.json',
|
||||
'./packages/calendar/tsconfig.json',
|
||||
'./packages/gmail/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
|
||||
// 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: {
|
||||
parser: tsparser,
|
||||
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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
|
|
@ -99,6 +134,508 @@
|
|||
"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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
|
|
@ -1439,6 +1976,14 @@
|
|||
"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": {
|
||||
"resolved": "packages/calendar",
|
||||
"link": true
|
||||
|
|
@ -1487,6 +2032,135 @@
|
|||
"resolved": "packages/tasks",
|
||||
"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": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
|
|
@ -2674,6 +3348,13 @@
|
|||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
|
|
@ -4438,6 +5119,16 @@
|
|||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -4500,6 +5191,13 @@
|
|||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
|
@ -5536,6 +6234,13 @@
|
|||
"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": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@
|
|||
},
|
||||
{
|
||||
"path": "./servers/sh-mcp-finance"
|
||||
},
|
||||
{
|
||||
"path": "./auth/pre-token-gen"
|
||||
},
|
||||
{
|
||||
"path": "./auth/group-sync"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue