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