sh-mcp/infra/lib/auth-stack.ts

348 lines
14 KiB
TypeScript
Raw Normal View History

Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas (#4) * Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas Stands up the real AWS auth broker the servers already validate against (SH_MCP_ENV=aws), surface-agnostic. Nothing deployed yet (gated on Google secrets); CI synthesizes the stack. infra/ — root CDK app, stack sh-mcp-auth: - Cognito user pool, ESSENTIALS feature plan (required for the V2 pre-token trigger), Google external OIDC IdP (client_id/secret resolved from Secrets Manager at deploy via CFN dynamic reference, never inlined). - Resource servers + per-tier app clients whose AllowedOAuthScopes ARE the trust-tier boundary: ops=(read,tasks), exec=(ops+gmail/calendar, NO finance), finance=(finance:read ONLY, 15-min access TTL). offline refresh 30d. - Cognito groups sh-mcp-ops/-assistant/-finance/-admin. - sync-state + deny-list DynamoDB tables (overrideLogicalId pinned so a future refactor cannot replace+drop them; deny-list TTL attr 'expiresAt'). - Least-priv IAM (no wildcard action/resource; Google SA secret grant scoped to the one secret), arm64 Lambdas, explicit 60-day log groups, alarms on the seahaven-alarm-topics CMK (ALARM-state actions only, two-alarm group-sync). auth/pre-token-gen — SUPPRESS-ONLY V2 Lambda. Maps Cognito group entitlement to scopesToSuppress; NEVER scopesToAdd a tier scope (AllowedOAuthScopes stays the ceiling). Reads last_successful_sync; fail-closed to base ops:read when stale. auth/group-sync — mirrors Google Group membership into Cognito groups every 5 min (jose-signed SA JWT -> Directory API, no googleapis dep); writes the freshness marker ONLY on full success so a partial failure keeps the pre-token Lambda failing closed. 37 new tests (suppress-only policy, fail-closed, reconcile diff, 16 CDK assertions incl. Essentials/V2/per-client-scope/no-wildcard-IAM). 448 total pass; tsc -b + infra typecheck + cdk synth + prettier clean; CI run-cdk-synth re-enabled. App-client callback URLs are a context placeholder pending the surface decision. Confluence map (1540098) + project memory updates owed once this deploys. * Phase 2a: harden auth substrate per security-review + IAM cross-review Both mandatory gates run on the 2a diff. GPT-4.1 IAM/Lambda cross-review: the suppress-only invariant is now an executable fail-closed guard (a future edit that sets scopesToAdd throws → no token minted). /sh-security-review fan-out + proof-or-kill verifier: PASS (0 confirmed critical/high). The verifier refuted the two "high" candidates (the email-case revocation "bypass" is symmetric — the add path uses the same lowercasing filter, so an un-removable user could never have been added; the empty-directory purge is a non-200 throw → stale marker → fail closed). Three confirmed findings remediated: - C2 (deny-list was inert): the sh-mcp-deny-list table was provisioned and documented as "hard revocation" but no code read it. The pre-token Lambda now reads it on every mint (DENY_LIST_TABLE env + grantReadData) and strips a deny-listed sub to NO tier scopes, ahead of the next group sync. Fail-OPEN on a DDB read error (logs deny_list_read_failed) so a blip can't lock everyone out — group membership + its fail-closed 30-min window stay authoritative. - C5 (finance 30-day refresh nullified the 15-min access TTL): refresh window is now per-tier; finance caps at 8h, ops/exec keep 30d. - C7 (nested Google-group members silently dropped): listGroupMembers now sets includeDerivedMembership and skips non-USER rows, honoring the documented "nested resolved" contract instead of pushing a phantom group address. Also corrects the sync.ts comment that overstated fail-closed as instantaneous (it is bounded by MAX_SYNC_AGE_MS). +8 tests (deny-list unit, hard-revocation handler path, finance refresh window, deny-list env wiring); 456 pass. tsc -b, cdk synth, prettier, eslint all clean.
2026-06-26 14:33:46 -04:00
/**
* 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' });
}
}