From b5e604dabea00a551c225d1ec62a68fc25538ab4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:33:46 -0400 Subject: [PATCH] Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .github/workflows/ci.yaml | 6 +- README.md | 39 +- auth/group-sync/package.json | 32 + auth/group-sync/src/aws.ts | 122 ++++ auth/group-sync/src/clients.ts | 112 ++++ auth/group-sync/src/constants.ts | 5 + auth/group-sync/src/groups.ts | 61 ++ auth/group-sync/src/index.ts | 51 ++ auth/group-sync/src/sync.ts | 61 ++ auth/group-sync/test/groups.test.ts | 39 ++ auth/group-sync/test/sync.test.ts | 80 +++ auth/group-sync/tsconfig.json | 9 + auth/pre-token-gen/package.json | 27 + auth/pre-token-gen/src/deny-list.ts | 51 ++ auth/pre-token-gen/src/index.ts | 95 +++ auth/pre-token-gen/src/scopes.ts | 81 +++ auth/pre-token-gen/src/sync-state.ts | 54 ++ auth/pre-token-gen/test/deny-list.test.ts | 38 ++ auth/pre-token-gen/test/handler.test.ts | 98 +++ auth/pre-token-gen/test/scopes.test.ts | 75 +++ auth/pre-token-gen/tsconfig.json | 9 + cdk.json | 7 + eslint.config.js | 12 +- infra/bin/app.ts | 19 + infra/lib/auth-stack.ts | 347 +++++++++++ infra/package.json | 25 + infra/test/auth-stack.test.ts | 196 ++++++ infra/tsconfig.json | 14 + package-lock.json | 705 ++++++++++++++++++++++ tsconfig.json | 6 + 30 files changed, 2469 insertions(+), 7 deletions(-) create mode 100644 auth/group-sync/package.json create mode 100644 auth/group-sync/src/aws.ts create mode 100644 auth/group-sync/src/clients.ts create mode 100644 auth/group-sync/src/constants.ts create mode 100644 auth/group-sync/src/groups.ts create mode 100644 auth/group-sync/src/index.ts create mode 100644 auth/group-sync/src/sync.ts create mode 100644 auth/group-sync/test/groups.test.ts create mode 100644 auth/group-sync/test/sync.test.ts create mode 100644 auth/group-sync/tsconfig.json create mode 100644 auth/pre-token-gen/package.json create mode 100644 auth/pre-token-gen/src/deny-list.ts create mode 100644 auth/pre-token-gen/src/index.ts create mode 100644 auth/pre-token-gen/src/scopes.ts create mode 100644 auth/pre-token-gen/src/sync-state.ts create mode 100644 auth/pre-token-gen/test/deny-list.test.ts create mode 100644 auth/pre-token-gen/test/handler.test.ts create mode 100644 auth/pre-token-gen/test/scopes.test.ts create mode 100644 auth/pre-token-gen/tsconfig.json create mode 100644 cdk.json create mode 100644 infra/bin/app.ts create mode 100644 infra/lib/auth-stack.ts create mode 100644 infra/package.json create mode 100644 infra/test/auth-stack.test.ts create mode 100644 infra/tsconfig.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3db5bce..abf8eec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/README.md b/README.md index 30512b8..bc51a1b 100644 --- a/README.md +++ b/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) diff --git a/auth/group-sync/package.json b/auth/group-sync/package.json new file mode 100644 index 0000000..7292396 --- /dev/null +++ b/auth/group-sync/package.json @@ -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" + } +} diff --git a/auth/group-sync/src/aws.ts b/auth/group-sync/src/aws.ts new file mode 100644 index 0000000..67b1739 --- /dev/null +++ b/auth/group-sync/src/aws.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.doc.send( + new PutCommand({ + TableName: this.tableName, + Item: { + pk: SYNC_STATE_PK, + lastSuccessfulSyncMs: epochMs, + updatedAt: new Date(epochMs).toISOString(), + }, + }), + ); + } +} diff --git a/auth/group-sync/src/clients.ts b/auth/group-sync/src/clients.ts new file mode 100644 index 0000000..717e05b --- /dev/null +++ b/auth/group-sync/src/clients.ts @@ -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; +} + +/** Reconciles membership of a Cognito group. */ +export interface CognitoGroupTarget { + /** Create the group if it does not already exist (idempotent). */ + ensureGroup(groupName: string): Promise; + /** Email addresses currently in the Cognito group. */ + listMembers(groupName: string): Promise; + addMember(groupName: string, email: string): Promise; + removeMember(groupName: string, email: string): Promise; +} + +/** Persists the freshness marker the pre-token Lambda fails closed on. */ +export interface SyncStateWriter { + writeLastSuccessfulSync(epochMs: number): Promise; +} + +// --------------------------------------------------------------------------- +// 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 { + 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 { + 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; + } +} diff --git a/auth/group-sync/src/constants.ts b/auth/group-sync/src/constants.ts new file mode 100644 index 0000000..84af12a --- /dev/null +++ b/auth/group-sync/src/constants.ts @@ -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'; diff --git a/auth/group-sync/src/groups.ts b/auth/group-sync/src/groups.ts new file mode 100644 index 0000000..fd7c9a3 --- /dev/null +++ b/auth/group-sync/src/groups.ts @@ -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)), + }; +} diff --git a/auth/group-sync/src/index.ts b/auth/group-sync/src/index.ts new file mode 100644 index 0000000..5a4e0e1 --- /dev/null +++ b/auth/group-sync/src/index.ts @@ -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 { + 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 }; +} diff --git a/auth/group-sync/src/sync.ts b/auth/group-sync/src/sync.ts new file mode 100644 index 0000000..8875af1 --- /dev/null +++ b/auth/group-sync/src/sync.ts @@ -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 { + 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 { + 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; +} diff --git a/auth/group-sync/test/groups.test.ts b/auth/group-sync/test/groups.test.ts new file mode 100644 index 0000000..fe62e14 --- /dev/null +++ b/auth/group-sync/test/groups.test.ts @@ -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'); + }); +}); diff --git a/auth/group-sync/test/sync.test.ts b/auth/group-sync/test/sync.test.ts new file mode 100644 index 0000000..fc4cdd6 --- /dev/null +++ b/auth/group-sync/test/sync.test.ts @@ -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, + cognitoMembers: Record, +) { + 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).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).mockRejectedValueOnce( + new Error('cognito throttled'), + ); + await expect(runSync(deps)).rejects.toThrow('cognito throttled'); + expect(writes).toEqual([]); + }); +}); diff --git a/auth/group-sync/tsconfig.json b/auth/group-sync/tsconfig.json new file mode 100644 index 0000000..64b6b66 --- /dev/null +++ b/auth/group-sync/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declarationDir": "dist" + }, + "include": ["src"] +} diff --git a/auth/pre-token-gen/package.json b/auth/pre-token-gen/package.json new file mode 100644 index 0000000..c7b2d5c --- /dev/null +++ b/auth/pre-token-gen/package.json @@ -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" + } +} diff --git a/auth/pre-token-gen/src/deny-list.ts b/auth/pre-token-gen/src/deny-list.ts new file mode 100644 index 0000000..1472aa0 --- /dev/null +++ b/auth/pre-token-gen/src/deny-list.ts @@ -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 { + 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; + } +} diff --git a/auth/pre-token-gen/src/index.ts b/auth/pre-token-gen/src/index.ts new file mode 100644 index 0000000..591d6bb --- /dev/null +++ b/auth/pre-token-gen/src/index.ts @@ -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; +} + +export async function handler(event: PreTokenEvent): Promise { + 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 }; diff --git a/auth/pre-token-gen/src/scopes.ts b/auth/pre-token-gen/src/scopes.ts new file mode 100644 index 0000000..d2a5870 --- /dev/null +++ b/auth/pre-token-gen/src/scopes.ts @@ -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> = { + '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(); + 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)); +} diff --git a/auth/pre-token-gen/src/sync-state.ts b/auth/pre-token-gen/src/sync-state.ts new file mode 100644 index 0000000..04ea73d --- /dev/null +++ b/auth/pre-token-gen/src/sync-state.ts @@ -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 { + 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; +} diff --git a/auth/pre-token-gen/test/deny-list.test.ts b/auth/pre-token-gen/test/deny-list.test.ts new file mode 100644 index 0000000..06be535 --- /dev/null +++ b/auth/pre-token-gen/test/deny-list.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { isSubDenied } from '../src/deny-list.js'; + +function fakeClient(send: () => Promise) { + return { send: vi.fn(send) } as unknown as Parameters[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(); + }); +}); diff --git a/auth/pre-token-gen/test/handler.test.ts b/auth/pre-token-gen/test/handler.test.ts new file mode 100644 index 0000000..47f0ac7 --- /dev/null +++ b/auth/pre-token-gen/test/handler.test.ts @@ -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(); + 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>) { + 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); + }); +}); diff --git a/auth/pre-token-gen/test/scopes.test.ts b/auth/pre-token-gen/test/scopes.test.ts new file mode 100644 index 0000000..7fea83f --- /dev/null +++ b/auth/pre-token-gen/test/scopes.test.ts @@ -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); + } + } + }); +}); diff --git a/auth/pre-token-gen/tsconfig.json b/auth/pre-token-gen/tsconfig.json new file mode 100644 index 0000000..64b6b66 --- /dev/null +++ b/auth/pre-token-gen/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declarationDir": "dist" + }, + "include": ["src"] +} diff --git a/cdk.json b/cdk.json new file mode 100644 index 0000000..006d64a --- /dev/null +++ b/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "tsx infra/bin/app.ts", + "output": "cdk.out", + "context": { + "@aws-cdk/core:newStyleStackSynthesis": true + } +} diff --git a/eslint.config.js b/eslint.config.js index 719480d..d1423f6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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: { diff --git a/infra/bin/app.ts b/infra/bin/app.ts new file mode 100644 index 0000000..3ff084e --- /dev/null +++ b/infra/bin/app.ts @@ -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(); diff --git a/infra/lib/auth-stack.ts b/infra/lib/auth-stack.ts new file mode 100644 index 0000000..089f0d9 --- /dev/null +++ b/infra/lib/auth-stack.ts @@ -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' }); + } +} diff --git a/infra/package.json b/infra/package.json new file mode 100644 index 0000000..fbdb821 --- /dev/null +++ b/infra/package.json @@ -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" + } +} diff --git a/infra/test/auth-stack.test.ts b/infra/test/auth-stack.test.ts new file mode 100644 index 0000000..528b097 --- /dev/null +++ b/infra/test/auth-stack.test.ts @@ -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 = { 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)', + }); + }); +}); diff --git a/infra/tsconfig.json b/infra/tsconfig.json new file mode 100644 index 0000000..0f3b6c3 --- /dev/null +++ b/infra/tsconfig.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json index 6efd141..b2ebe2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/tsconfig.json b/tsconfig.json index 04c4ab2..bc3b9d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,12 @@ }, { "path": "./servers/sh-mcp-finance" + }, + { + "path": "./auth/pre-token-gen" + }, + { + "path": "./auth/group-sync" } ] }