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:
daniel
2026-05-06 21:42:33 -05:00
parent 841e729b18
commit ac5bd78d5a
24 changed files with 1736 additions and 95 deletions

View File

@@ -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', {