multi-tenant Phase 2: per-user Google OAuth
- workspace-mcp: add proxy.py (port 8080) that reads X-Actor-Id header,
fetches per-user Google credentials from Secrets Manager, writes creds
file, sets USER_GOOGLE_EMAIL, proxies to workspace-mcp on port 8081
- workspace-mcp: update bootstrap to start workspace-mcp on 8081 + proxy on 8080
- workspace-mcp: update Dockerfile to include proxy.py
- oauth-handler Lambda: new Lambda with /oauth/start + /oauth/callback
routes; exchanges Google auth code, stores tokens in Secrets Manager
at agent-claw/google-credentials/{actor_id_safe}, updates DynamoDB
- CDK: add OAuthHandler Lambda + GET /oauth/start + /oauth/callback routes
- CDK: remove shared google-workspace-credentials secret; add per-user
secret IAM grants (agent-claw/google-credentials/*) for workspace-mcp
role, runtime1 role, and oauth-handler role
- CDK: output OAuthStartUrl + OAuthRedirectUri
- agent-runner: pass google_email in user_profile payload
- main.py: pass actor_id as X-Actor-Id header in workspace-mcp MCP calls;
skip workspace-mcp if user has no google_email; add connect_google_account
tool that generates OAuth URL for the current user
- main.py: include google_email in user_context for system prompt
- agentcore.json: add OAUTH_START_URL env var for agent runtime
This commit is contained in:
@@ -87,6 +87,13 @@ class AgentClawStack extends cdk.Stack {
|
||||
timeToLiveAttribute: 'ttl',
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
// ── DynamoDB user registry ─────────────────────────────────────────────
|
||||
const usersTable = new dynamodb.Table(this, 'UsersTable', {
|
||||
tableName: 'agent-claw-users',
|
||||
partitionKey: { name: 'actor_id', type: dynamodb.AttributeType.STRING },
|
||||
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
// ── SQS FIFO message queue ─────────────────────────────────────────────
|
||||
const messageQueue = new sqs.Queue(this, 'MessageQueue', {
|
||||
queueName: 'agent-claw-messages.fifo',
|
||||
@@ -129,6 +136,8 @@ class AgentClawStack extends cdk.Stack {
|
||||
},
|
||||
});
|
||||
sessionTable.grantReadWriteData(agentRunnerFn);
|
||||
usersTable.grantReadWriteData(agentRunnerFn);
|
||||
agentRunnerFn.addEnvironment('USERS_TABLE_NAME', usersTable.tableName);
|
||||
workspaceBucket.grantRead(agentRunnerFn);
|
||||
botTokenSecret.grantRead(agentRunnerFn);
|
||||
braveApiKeySecret.grantRead(agentRunnerFn);
|
||||
@@ -185,18 +194,17 @@ class AgentClawStack extends cdk.Stack {
|
||||
// TODO: Replace with L2 construct when aws-cdk-lib includes it stable
|
||||
// For now, the runtime ARN must be created manually or via CLI after first synth
|
||||
// and fed back as context param runtime1Arn.
|
||||
// ── Outputs ────────────────────────────────────────────────────────────
|
||||
// ── Google Workspace MCP ──────────────────────────────────────────────
|
||||
// Secrets pre-populated after OAuth flow
|
||||
const googleCredentialsSecret = secretsmanager.Secret.fromSecretNameV2(this, 'GoogleWorkspaceCredentials', 'agent-claw/google-workspace-credentials');
|
||||
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretNameV2(this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client');
|
||||
// workspace-mcp Lambda execution role (import existing — created during initial setup)
|
||||
// NOTE (tech debt #3): workspaceMcpRole imported but not attached to workspaceMcpFn because
|
||||
// fromFunctionName() returns an IFunction (no role config). Role was set at Lambda creation.
|
||||
// To fully codify: delete the manual Lambda, let CDK create it with Code.fromBucket + role.
|
||||
const _workspaceMcpRole = iam.Role.fromRoleName(this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role');
|
||||
googleCredentialsSecret.grantRead(_workspaceMcpRole);
|
||||
googleOAuthClientSecret.grantRead(_workspaceMcpRole);
|
||||
// Grant workspace-mcp role read access to all per-user Google credential secrets
|
||||
_workspaceMcpRole.addToPrincipalPolicy?.(new iam.PolicyStatement({
|
||||
sid: 'PerUserGoogleCredentialsRead',
|
||||
actions: ['secretsmanager:GetSecretValue'],
|
||||
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
|
||||
}));
|
||||
// workspace-mcp Lambda — import existing (created with zip + layer, no Docker)
|
||||
const workspaceMcpFn = lambda.Function.fromFunctionName(this, 'WorkspaceMcp', 'agent-claw-workspace-mcp');
|
||||
// Function URL — AWS_IAM auth (already created, reference for policy attachment)
|
||||
@@ -209,18 +217,72 @@ class AgentClawStack extends cdk.Stack {
|
||||
resources: [workspaceMcpFn.functionArn],
|
||||
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
|
||||
}));
|
||||
// Grant AgentCore execution role read access to Google OAuth client + per-user credentials
|
||||
googleOAuthClientSecret.grantRead(runtime1Role);
|
||||
runtime1Role.addToPolicy(new iam.PolicyStatement({
|
||||
sid: 'PerUserGoogleCredentialsReadRuntime',
|
||||
actions: ['secretsmanager:GetSecretValue'],
|
||||
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
|
||||
}));
|
||||
// Pass workspace_mcp MCP URL to agent-runner (informational)
|
||||
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
|
||||
// Grant AgentCore execution role read access to Google secrets
|
||||
googleCredentialsSecret.grantRead(runtime1Role);
|
||||
googleOAuthClientSecret.grantRead(runtime1Role);
|
||||
// ── OAuth Handler Lambda ───────────────────────────────────────────────
|
||||
const oauthHandlerFn = new lambda.Function(this, 'OAuthHandler', {
|
||||
functionName: 'agent-claw-oauth-handler',
|
||||
runtime: lambda.Runtime.PYTHON_3_12,
|
||||
handler: 'handler.handler',
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambdas/oauth-handler')),
|
||||
timeout: cdk.Duration.seconds(30),
|
||||
memorySize: 128,
|
||||
environment: {
|
||||
GOOGLE_OAUTH_CLIENT_SECRET_ARN: googleOAuthClientSecret.secretArn,
|
||||
USERS_TABLE_NAME: usersTable.tableName,
|
||||
// OAUTH_REDIRECT_URI set after API GW URL is known — injected via addEnvironment below
|
||||
OAUTH_REDIRECT_URI: 'PLACEHOLDER',
|
||||
},
|
||||
});
|
||||
googleOAuthClientSecret.grantRead(oauthHandlerFn);
|
||||
usersTable.grantReadWriteData(oauthHandlerFn);
|
||||
// Grant OAuth handler write access to per-user credential secrets
|
||||
oauthHandlerFn.addToRolePolicy(new iam.PolicyStatement({
|
||||
sid: 'PerUserGoogleCredentialsWrite',
|
||||
actions: [
|
||||
'secretsmanager:CreateSecret',
|
||||
'secretsmanager:PutSecretValue',
|
||||
'secretsmanager:GetSecretValue',
|
||||
],
|
||||
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
|
||||
}));
|
||||
// Add OAuth routes to existing HTTP API
|
||||
httpApi.addRoutes({
|
||||
path: '/oauth/start',
|
||||
methods: [apigatewayv2.HttpMethod.GET],
|
||||
integration: new apigatewayv2integrations.HttpLambdaIntegration('OAuthStartIntegration', oauthHandlerFn),
|
||||
});
|
||||
httpApi.addRoutes({
|
||||
path: '/oauth/callback',
|
||||
methods: [apigatewayv2.HttpMethod.GET],
|
||||
integration: new apigatewayv2integrations.HttpLambdaIntegration('OAuthCallbackIntegration', oauthHandlerFn),
|
||||
});
|
||||
// Set OAUTH_REDIRECT_URI now that we have the API URL
|
||||
const oauthRedirectUri = `${httpApi.url}oauth/callback`;
|
||||
oauthHandlerFn.addEnvironment('OAUTH_REDIRECT_URI', oauthRedirectUri);
|
||||
// Pass OAuth start URL to AgentCore runtime so agent can generate connect links
|
||||
const oauthStartUrl = `${httpApi.url}oauth/start`;
|
||||
// NOTE: AgentCore runtime env vars are set in agentcore.json / agentcore deploy, not CDK.
|
||||
// Output the URL so it can be manually set in agentcore.json OAUTH_START_URL.
|
||||
// ── Outputs ────────────────────────────────────────────────────────────
|
||||
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {
|
||||
value: workspaceMcpFunctionUrl,
|
||||
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'GoogleCredentialsSecretArn', {
|
||||
value: googleCredentialsSecret.secretArn,
|
||||
description: 'Google OAuth user credentials secret ARN',
|
||||
new cdk.CfnOutput(this, 'OAuthStartUrl', {
|
||||
value: oauthStartUrl,
|
||||
description: 'Google OAuth start URL — set as OAUTH_START_URL in agentcore.json',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'OAuthRedirectUri', {
|
||||
value: oauthRedirectUri,
|
||||
description: 'Google OAuth redirect URI — register in Google Cloud Console',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'WebhookUrl', {
|
||||
value: `${httpApi.url}telegram`,
|
||||
@@ -234,6 +296,10 @@ class AgentClawStack extends cdk.Stack {
|
||||
value: sessionTable.tableName,
|
||||
description: 'DynamoDB table for session mapping',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'UsersTableName', {
|
||||
value: usersTable.tableName,
|
||||
description: 'DynamoDB user registry table',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'MessageQueueUrl', {
|
||||
value: messageQueue.queueUrl,
|
||||
description: 'SQS FIFO queue for incoming messages',
|
||||
|
||||
@@ -186,26 +186,22 @@ export class AgentClawStack extends cdk.Stack {
|
||||
// For now, the runtime ARN must be created manually or via CLI after first synth
|
||||
// and fed back as context param runtime1Arn.
|
||||
|
||||
// ── Outputs ────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Google Workspace MCP ──────────────────────────────────────────────
|
||||
// Secrets pre-populated after OAuth flow
|
||||
const googleCredentialsSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
this, 'GoogleWorkspaceCredentials', 'agent-claw/google-workspace-credentials'
|
||||
);
|
||||
const googleOAuthClientSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
this, 'GoogleOAuthClient', 'agent-claw/google-oauth-client'
|
||||
);
|
||||
|
||||
// workspace-mcp Lambda execution role (import existing — created during initial setup)
|
||||
// NOTE (tech debt #3): workspaceMcpRole imported but not attached to workspaceMcpFn because
|
||||
// fromFunctionName() returns an IFunction (no role config). Role was set at Lambda creation.
|
||||
// To fully codify: delete the manual Lambda, let CDK create it with Code.fromBucket + role.
|
||||
const _workspaceMcpRole = iam.Role.fromRoleName(
|
||||
this, 'WorkspaceMcpRole', 'agent-claw-workspace-mcp-role'
|
||||
);
|
||||
googleCredentialsSecret.grantRead(_workspaceMcpRole);
|
||||
googleOAuthClientSecret.grantRead(_workspaceMcpRole);
|
||||
// Grant workspace-mcp role read access to all per-user Google credential secrets
|
||||
(_workspaceMcpRole as iam.Role).addToPrincipalPolicy?.(new iam.PolicyStatement({
|
||||
sid: 'PerUserGoogleCredentialsRead',
|
||||
actions: ['secretsmanager:GetSecretValue'],
|
||||
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
|
||||
}));
|
||||
|
||||
// workspace-mcp Lambda — import existing (created with zip + layer, no Docker)
|
||||
const workspaceMcpFn = lambda.Function.fromFunctionName(
|
||||
@@ -224,20 +220,83 @@ export class AgentClawStack extends cdk.Stack {
|
||||
conditions: { StringEquals: { 'lambda:FunctionUrlAuthType': 'AWS_IAM' } },
|
||||
}));
|
||||
|
||||
// Grant AgentCore execution role read access to Google OAuth client + per-user credentials
|
||||
googleOAuthClientSecret.grantRead(runtime1Role);
|
||||
runtime1Role.addToPolicy(new iam.PolicyStatement({
|
||||
sid: 'PerUserGoogleCredentialsReadRuntime',
|
||||
actions: ['secretsmanager:GetSecretValue'],
|
||||
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
|
||||
}));
|
||||
|
||||
// Pass workspace_mcp MCP URL to agent-runner (informational)
|
||||
agentRunnerFn.addEnvironment('WORKSPACE_MCP_URL', workspaceMcpMcpUrl);
|
||||
|
||||
// Grant AgentCore execution role read access to Google secrets
|
||||
googleCredentialsSecret.grantRead(runtime1Role);
|
||||
googleOAuthClientSecret.grantRead(runtime1Role);
|
||||
// ── OAuth Handler Lambda ───────────────────────────────────────────────
|
||||
const oauthHandlerFn = new lambda.Function(this, 'OAuthHandler', {
|
||||
functionName: 'agent-claw-oauth-handler',
|
||||
runtime: lambda.Runtime.PYTHON_3_12,
|
||||
handler: 'handler.handler',
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, '../../src/lambdas/oauth-handler')),
|
||||
timeout: cdk.Duration.seconds(30),
|
||||
memorySize: 128,
|
||||
environment: {
|
||||
GOOGLE_OAUTH_CLIENT_SECRET_ARN: googleOAuthClientSecret.secretArn,
|
||||
USERS_TABLE_NAME: usersTable.tableName,
|
||||
// OAUTH_REDIRECT_URI set after API GW URL is known — injected via addEnvironment below
|
||||
OAUTH_REDIRECT_URI: 'PLACEHOLDER',
|
||||
},
|
||||
});
|
||||
googleOAuthClientSecret.grantRead(oauthHandlerFn);
|
||||
usersTable.grantReadWriteData(oauthHandlerFn);
|
||||
// Grant OAuth handler write access to per-user credential secrets
|
||||
oauthHandlerFn.addToRolePolicy(new iam.PolicyStatement({
|
||||
sid: 'PerUserGoogleCredentialsWrite',
|
||||
actions: [
|
||||
'secretsmanager:CreateSecret',
|
||||
'secretsmanager:PutSecretValue',
|
||||
'secretsmanager:GetSecretValue',
|
||||
],
|
||||
resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:agent-claw/google-credentials/*`],
|
||||
}));
|
||||
|
||||
// Add OAuth routes to existing HTTP API
|
||||
httpApi.addRoutes({
|
||||
path: '/oauth/start',
|
||||
methods: [apigatewayv2.HttpMethod.GET],
|
||||
integration: new apigatewayv2integrations.HttpLambdaIntegration(
|
||||
'OAuthStartIntegration', oauthHandlerFn
|
||||
),
|
||||
});
|
||||
httpApi.addRoutes({
|
||||
path: '/oauth/callback',
|
||||
methods: [apigatewayv2.HttpMethod.GET],
|
||||
integration: new apigatewayv2integrations.HttpLambdaIntegration(
|
||||
'OAuthCallbackIntegration', oauthHandlerFn
|
||||
),
|
||||
});
|
||||
|
||||
// Set OAUTH_REDIRECT_URI now that we have the API URL
|
||||
const oauthRedirectUri = `${httpApi.url}oauth/callback`;
|
||||
oauthHandlerFn.addEnvironment('OAUTH_REDIRECT_URI', oauthRedirectUri);
|
||||
|
||||
// Pass OAuth start URL to AgentCore runtime so agent can generate connect links
|
||||
const oauthStartUrl = `${httpApi.url}oauth/start`;
|
||||
// NOTE: AgentCore runtime env vars are set in agentcore.json / agentcore deploy, not CDK.
|
||||
// Output the URL so it can be manually set in agentcore.json OAUTH_START_URL.
|
||||
|
||||
// ── Outputs ────────────────────────────────────────────────────────────
|
||||
|
||||
new cdk.CfnOutput(this, 'WorkspaceMcpFunctionUrl', {
|
||||
value: workspaceMcpFunctionUrl,
|
||||
description: 'workspace-mcp Lambda Function URL (MCP endpoint for Gmail/Calendar)',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'GoogleCredentialsSecretArn', {
|
||||
value: googleCredentialsSecret.secretArn,
|
||||
description: 'Google OAuth user credentials secret ARN',
|
||||
new cdk.CfnOutput(this, 'OAuthStartUrl', {
|
||||
value: oauthStartUrl,
|
||||
description: 'Google OAuth start URL — set as OAUTH_START_URL in agentcore.json',
|
||||
});
|
||||
new cdk.CfnOutput(this, 'OAuthRedirectUri', {
|
||||
value: oauthRedirectUri,
|
||||
description: 'Google OAuth redirect URI — register in Google Cloud Console',
|
||||
});
|
||||
|
||||
new cdk.CfnOutput(this, 'WebhookUrl', {
|
||||
|
||||
Reference in New Issue
Block a user