diff --git a/apps/docs/content/docs/en/integrations/secrets_manager.mdx b/apps/docs/content/docs/en/integrations/secrets_manager.mdx index ca685d9de1b..db33506e7c0 100644 --- a/apps/docs/content/docs/en/integrations/secrets_manager.mdx +++ b/apps/docs/content/docs/en/integrations/secrets_manager.mdx @@ -28,7 +28,7 @@ In Sim, the AWS Secrets Manager integration allows your workflows to securely re ## Usage Instructions -Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets. +Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, delete, describe, tag, untag, restore, and rotate secrets. @@ -78,7 +78,7 @@ List secrets stored in AWS Secrets Manager | Parameter | Type | Description | | --------- | ---- | ----------- | -| `secrets` | json | List of secrets with name, ARN, description, and dates | +| `secrets` | json | List of secrets with name, ARN, description, dates, rotation rules/window, and version-to-stage mappings | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of secrets returned | @@ -154,4 +154,131 @@ Delete a secret from AWS Secrets Manager | `arn` | string | ARN of the deleted secret | | `deletionDate` | string | Scheduled deletion date | +### `secrets_manager_describe_secret` + +Retrieve full metadata for a secret in AWS Secrets Manager, including rotation configuration and replication status, without exposing the secret value + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to describe | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the secret | +| `arn` | string | ARN of the secret | +| `description` | string | Description of the secret | +| `kmsKeyId` | string | KMS key ID used to encrypt the secret | +| `rotationEnabled` | boolean | Whether automatic rotation is enabled | +| `rotationLambdaARN` | string | ARN of the Lambda function used for rotation | +| `rotationRules` | json | Rotation schedule configuration | +| `lastRotatedDate` | string | Date the secret was last rotated | +| `lastChangedDate` | string | Date the secret was last changed | +| `lastAccessedDate` | string | Date the secret was last accessed | +| `deletedDate` | string | Scheduled deletion date | +| `nextRotationDate` | string | Date the secret is next scheduled to rotate | +| `tags` | array | Tags attached to the secret | +| `versionIdsToStages` | json | Map of version IDs to their staging labels | +| `owningService` | string | ID of the AWS service that manages this secret, if any | +| `createdDate` | string | Date the secret was created | +| `primaryRegion` | string | The primary region of the secret, if replicated | +| `replicationStatus` | array | Replication status for each region the secret is replicated to | + +### `secrets_manager_tag_resource` + +Attach tags to a secret in AWS Secrets Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to tag | +| `tags` | json | Yes | Tags to attach, as an array of \{key, value\} pairs \(max 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name or ARN of the tagged secret | + +### `secrets_manager_untag_resource` + +Remove tags from a secret in AWS Secrets Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to untag | +| `tagKeys` | json | Yes | Tag keys to remove, as an array of strings \(max 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name or ARN of the untagged secret | + +### `secrets_manager_restore_secret` + +Cancel a scheduled deletion for a secret in AWS Secrets Manager, restoring access to it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to restore | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name of the restored secret | +| `arn` | string | ARN of the restored secret | + +### `secrets_manager_rotate_secret` + +Start or reconfigure rotation for a secret in AWS Secrets Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to rotate | +| `clientRequestToken` | string | No | Idempotency token for the new secret version \(32-64 characters\) | +| `rotationLambdaARN` | string | No | ARN of the Lambda function that performs rotation \(omit for managed rotation\) | +| `automaticallyAfterDays` | number | No | Number of days between rotations \(1-1000\). Mutually exclusive with schedule expression | +| `duration` | string | No | Length of the rotation window in hours, e.g. "3h" | +| `scheduleExpression` | string | No | A cron\(\) or rate\(\) expression defining the rotation schedule | +| `rotateImmediately` | boolean | No | Whether to rotate immediately \(default true\) or wait for the next scheduled window | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name of the secret | +| `arn` | string | ARN of the secret | +| `versionId` | string | ID of the new secret version created by rotation | + diff --git a/apps/docs/content/docs/en/integrations/ses.mdx b/apps/docs/content/docs/en/integrations/ses.mdx index 71aab4dcb66..56c26ee5955 100644 --- a/apps/docs/content/docs/en/integrations/ses.mdx +++ b/apps/docs/content/docs/en/integrations/ses.mdx @@ -27,7 +27,7 @@ In Sim, the AWS SES integration is designed for workflows that need reliable, pr ## Usage Instructions -Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information. +Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates, identities, configuration sets, and the account suppression list, and retrieve account sending quota and verified identity information. @@ -238,4 +238,232 @@ Delete an existing SES email template | --------- | ---- | ----------- | | `message` | string | Confirmation message for the deleted template | +### `ses_update_template` + +Update the subject, HTML, and text content of an existing SES email template + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `templateName` | string | Yes | The name of the template to update | +| `subjectPart` | string | Yes | The subject line of the template | +| `htmlPart` | string | No | The HTML body of the template | +| `textPart` | string | No | The plain text body of the template | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_put_suppressed_destination` + +Add an email address to the account-level SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The email address to add to the suppression list | +| `reason` | string | Yes | The reason the address is suppressed: BOUNCE or COMPLAINT | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_delete_suppressed_destination` + +Remove an email address from the account-level SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The email address to remove from the suppression list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_get_suppressed_destination` + +Retrieve details for a specific email address on the SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The suppressed email address to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `emailAddress` | string | The suppressed email address | +| `reason` | string | The reason the address is suppressed | +| `lastUpdateTime` | string | When the address was added to the suppression list | +| `messageId` | string | The message ID associated with the bounce or complaint event | +| `feedbackId` | string | The feedback ID associated with the bounce or complaint event | + +### `ses_list_suppressed_destinations` + +List email addresses on the account-level SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `reasons` | string | No | Comma-separated suppression reasons to filter by: BOUNCE, COMPLAINT | +| `startDate` | string | No | Only include addresses suppressed after this ISO 8601 date | +| `endDate` | string | No | Only include addresses suppressed before this ISO 8601 date | +| `pageSize` | number | No | Maximum number of results to return | +| `nextToken` | string | No | Pagination token from a previous list response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `destinations` | array | List of suppressed destinations with email address, reason, and last update | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of suppressed destinations returned | + +### `ses_create_email_identity` + +Start verification of a new SES email address or domain identity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailIdentity` | string | Yes | The email address or domain to verify | +| `dkimSigningAttributes` | json | No | Bring-your-own-DKIM signing attributes as JSON \(domainSigningSelector, domainSigningPrivateKey, nextSigningKeyLength\). Domain identities only. | +| `tags` | json | No | JSON array of tags to associate with the identity: \[\{"key":"","value":""\}\] | +| `configurationSetName` | string | No | Default configuration set to use when sending from this identity | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `identityType` | string | The identity type: EMAIL_ADDRESS or DOMAIN | +| `verifiedForSendingStatus` | boolean | Whether the identity is verified and can send email | +| `dkimAttributes` | json | DKIM signing status and CNAME tokens for the identity | + +### `ses_delete_email_identity` + +Delete a verified SES email address or domain identity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailIdentity` | string | Yes | The email address or domain identity to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_get_email_identity` + +Retrieve verification status, DKIM, Mail-From, and policy details for an SES identity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailIdentity` | string | Yes | The email address or domain identity to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `identityType` | string | The identity type: EMAIL_ADDRESS or DOMAIN | +| `verifiedForSendingStatus` | boolean | Whether the identity is verified and can send email | +| `verificationStatus` | string | Verification status: PENDING, SUCCESS, FAILED, TEMPORARY_FAILURE, NOT_STARTED | +| `feedbackForwardingStatus` | boolean | Whether bounce/complaint notifications are forwarded by email | +| `configurationSetName` | string | Default configuration set for this identity | +| `dkimAttributes` | json | DKIM signing status and CNAME tokens for the identity | +| `mailFromAttributes` | json | Custom MAIL FROM domain configuration for the identity | +| `policies` | json | Sending authorization policies attached to the identity | +| `tags` | array | Tags associated with the identity | +| `verificationInfo` | json | Additional verification diagnostics \(error type, last checked/success time\) | + +### `ses_create_configuration_set` + +Create an SES configuration set to control tracking, delivery, reputation, sending, and suppression behavior for emails + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `configurationSetName` | string | Yes | Name of the configuration set \(letters, numbers, hyphens, underscores\) | +| `customRedirectDomain` | string | No | Custom domain to use for open/click tracking links | +| `httpsPolicy` | string | No | HTTPS policy for tracking links: REQUIRE, REQUIRE_OPEN_ONLY, or OPTIONAL | +| `tlsPolicy` | string | No | Whether delivery requires TLS: REQUIRE or OPTIONAL | +| `sendingPoolName` | string | No | Dedicated IP pool to associate with the configuration set | +| `reputationMetricsEnabled` | boolean | No | Whether to collect reputation metrics for emails using this configuration set | +| `sendingEnabled` | boolean | No | Whether sending is enabled for this configuration set | +| `suppressedReasons` | string | No | Comma-separated reasons that trigger suppression: BOUNCE, COMPLAINT | +| `tags` | json | No | JSON array of tags to associate with the configuration set: \[\{"key":"","value":""\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_send_custom_verification_email` + +Send a branded custom verification email to an address using a custom verification email template + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The email address to verify | +| `templateName` | string | Yes | The name of the custom verification email template to use | +| `configurationSetName` | string | No | Configuration set to use when sending the verification email | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messageId` | string | SES message ID for the sent verification email | + diff --git a/apps/docs/content/docs/en/integrations/sts.mdx b/apps/docs/content/docs/en/integrations/sts.mdx index 89c741398f8..4236096516b 100644 --- a/apps/docs/content/docs/en/integrations/sts.mdx +++ b/apps/docs/content/docs/en/integrations/sts.mdx @@ -50,6 +50,9 @@ Assume an IAM role and receive temporary security credentials | `externalId` | string | No | External ID for cross-account access | | `serialNumber` | string | No | MFA device serial number or ARN | | `tokenCode` | string | No | MFA token code \(6 digits\) | +| `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | +| `tags` | string | No | JSON object of up to 50 session tag key/value pairs for attribute-based access control | +| `transitiveTagKeys` | string | No | Comma-separated tag keys that propagate through role chaining | #### Output @@ -64,6 +67,73 @@ Assume an IAM role and receive temporary security credentials | `packedPolicySize` | number | Percentage of allowed policy size used | | `sourceIdentity` | string | Source identity set on the role session, if any | +### `sts_assume_role_with_web_identity` + +Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `roleArn` | string | Yes | ARN of the IAM role to assume | +| `roleSessionName` | string | Yes | Identifier for the assumed role session | +| `webIdentityToken` | string | Yes | OAuth 2.0 access token or OpenID Connect ID token from the identity provider \(up to 20000 chars\) | +| `providerId` | string | No | Fully qualified host of a legacy OAuth 2.0 provider \(e.g. www.amazon.com\); omit for OpenID Connect providers | +| `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | +| `policy` | string | No | JSON IAM policy to further restrict session permissions \(max 2048 chars\) | +| `durationSeconds` | number | No | Duration of the session in seconds \(900-43200, default 3600\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessKeyId` | string | Temporary access key ID | +| `secretAccessKey` | string | Temporary secret access key | +| `sessionToken` | string | Temporary session token | +| `expiration` | string | Credential expiration timestamp | +| `assumedRoleArn` | string | ARN of the assumed role | +| `assumedRoleId` | string | Assumed role ID with session name | +| `subjectFromWebIdentityToken` | string | Unique user identifier from the identity provider's token subject claim | +| `audience` | string | Intended audience \(client ID\) of the web identity token | +| `provider` | string | Issuing authority of the presented web identity token | +| `packedPolicySize` | number | Percentage of allowed policy size used | +| `sourceIdentity` | string | Source identity set on the role session, if any | + +### `sts_assume_role_with_saml` + +Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `roleArn` | string | Yes | ARN of the IAM role to assume | +| `principalArn` | string | Yes | ARN of the SAML provider in IAM that describes the identity provider | +| `samlAssertion` | string | Yes | Base64-encoded SAML authentication response from the identity provider | +| `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | +| `policy` | string | No | JSON IAM policy to further restrict session permissions \(max 2048 chars\) | +| `durationSeconds` | number | No | Duration of the session in seconds \(900-43200, default 3600\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessKeyId` | string | Temporary access key ID | +| `secretAccessKey` | string | Temporary secret access key | +| `sessionToken` | string | Temporary session token | +| `expiration` | string | Credential expiration timestamp | +| `assumedRoleArn` | string | ARN of the assumed role | +| `assumedRoleId` | string | Assumed role ID with session name | +| `subject` | string | Value of the NameID element in the Subject of the SAML assertion | +| `subjectType` | string | Format of the name ID \(e.g. transient, persistent\) | +| `issuer` | string | Value of the Issuer element of the SAML assertion | +| `audience` | string | Value of the SAML assertion's SubjectConfirmationData Recipient attribute | +| `nameQualifier` | string | Hash uniquely identifying the issuer, account, and SAML provider | +| `packedPolicySize` | number | Percentage of allowed policy size used | +| `sourceIdentity` | string | Source identity set on the role session, if any | + ### `sts_get_caller_identity` Get details about the IAM user or role whose credentials are used to call the API diff --git a/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts new file mode 100644 index 00000000000..793f8ce802b --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerDescribeSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-describe-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, describeSecret } from '../utils' + +const logger = createLogger('SecretsManagerDescribeSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerDescribeSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Describing secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await describeSecret(client, params.secretId) + + logger.info(`[${requestId}] Described secret: ${result.name}`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to describe secret:`, error) + + return NextResponse.json( + { error: `Failed to describe secret: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts new file mode 100644 index 00000000000..e73da9582a9 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts @@ -0,0 +1,58 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerRestoreSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-restore-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, restoreSecret } from '../utils' + +const logger = createLogger('SecretsManagerRestoreSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerRestoreSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Restoring secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await restoreSecret(client, params.secretId) + + logger.info(`[${requestId}] Restored secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" restored successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to restore secret:`, error) + + return NextResponse.json( + { error: `Failed to restore secret: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts new file mode 100644 index 00000000000..3a0718c4b90 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts @@ -0,0 +1,66 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerRotateSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-rotate-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, rotateSecret } from '../utils' + +const logger = createLogger('SecretsManagerRotateSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerRotateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Rotating secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await rotateSecret( + client, + params.secretId, + params.clientRequestToken, + params.rotationLambdaARN, + { + automaticallyAfterDays: params.automaticallyAfterDays, + duration: params.duration, + scheduleExpression: params.scheduleExpression, + }, + params.rotateImmediately + ) + + logger.info(`[${requestId}] Rotation started for secret: ${result.name}`) + + return NextResponse.json({ + message: `Rotation started for secret "${result.name}"`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to rotate secret:`, error) + + return NextResponse.json({ error: `Failed to rotate secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts b/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts new file mode 100644 index 00000000000..6c4bec10989 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts @@ -0,0 +1,59 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerTagResourceContract } from '@/lib/api/contracts/tools/aws/secrets-manager-tag-resource' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, tagResource } from '../utils' + +const logger = createLogger('SecretsManagerTagResourceAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerTagResourceContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Tagging secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await tagResource( + client, + params.secretId, + params.tags.map((t) => ({ Key: t.key, Value: t.value })) + ) + + logger.info(`[${requestId}] Tagged secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" tagged successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to tag secret:`, error) + + return NextResponse.json({ error: `Failed to tag secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts b/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts new file mode 100644 index 00000000000..9300e81008f --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSecretsManagerUntagResourceContract } from '@/lib/api/contracts/tools/aws/secrets-manager-untag-resource' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, untagResource } from '../utils' + +const logger = createLogger('SecretsManagerUntagResourceAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerUntagResourceContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Untagging secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await untagResource(client, params.secretId, params.tagKeys) + + logger.info(`[${requestId}] Untagged secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" untagged successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to untag secret:`, error) + + return NextResponse.json({ error: `Failed to untag secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/utils.ts b/apps/sim/app/api/tools/secrets_manager/utils.ts index dacbbc30be1..fc18d95b2fd 100644 --- a/apps/sim/app/api/tools/secrets_manager/utils.ts +++ b/apps/sim/app/api/tools/secrets_manager/utils.ts @@ -1,14 +1,28 @@ -import type { SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' +import type { RotationRulesType, SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' import { CreateSecretCommand, DeleteSecretCommand, + DescribeSecretCommand, GetSecretValueCommand, ListSecretsCommand, + RestoreSecretCommand, + RotateSecretCommand, SecretsManagerClient, + TagResourceCommand, + UntagResourceCommand, UpdateSecretCommand, } from '@aws-sdk/client-secrets-manager' import type { SecretsManagerConnectionConfig } from '@/tools/secrets_manager/types' +function mapRotationRules(rules: RotationRulesType | undefined) { + if (!rules) return null + return { + automaticallyAfterDays: rules.AutomaticallyAfterDays ?? null, + duration: rules.Duration ?? null, + scheduleExpression: rules.ScheduleExpression ?? null, + } +} + export function createSecretsManagerClient( config: SecretsManagerConnectionConfig ): SecretsManagerClient { @@ -71,6 +85,11 @@ export async function listSecrets( lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null, rotationEnabled: secret.RotationEnabled ?? false, tags: secret.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + rotationRules: mapRotationRules(secret.RotationRules), + lastRotatedDate: secret.LastRotatedDate?.toISOString() ?? null, + nextRotationDate: secret.NextRotationDate?.toISOString() ?? null, + deletedDate: secret.DeletedDate?.toISOString() ?? null, + secretVersionsToStages: secret.SecretVersionsToStages ?? null, })) return { @@ -139,3 +158,109 @@ export async function deleteSecret( deletionDate: response.DeletionDate?.toISOString() ?? null, } } + +export async function describeSecret(client: SecretsManagerClient, secretId: string) { + const command = new DescribeSecretCommand({ SecretId: secretId }) + const response = await client.send(command) + + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + description: response.Description ?? null, + kmsKeyId: response.KmsKeyId ?? null, + rotationEnabled: response.RotationEnabled ?? false, + rotationLambdaARN: response.RotationLambdaARN ?? null, + rotationRules: mapRotationRules(response.RotationRules), + lastRotatedDate: response.LastRotatedDate?.toISOString() ?? null, + lastChangedDate: response.LastChangedDate?.toISOString() ?? null, + lastAccessedDate: response.LastAccessedDate?.toISOString() ?? null, + deletedDate: response.DeletedDate?.toISOString() ?? null, + nextRotationDate: response.NextRotationDate?.toISOString() ?? null, + tags: response.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + versionIdsToStages: response.VersionIdsToStages ?? null, + owningService: response.OwningService ?? null, + createdDate: response.CreatedDate?.toISOString() ?? null, + primaryRegion: response.PrimaryRegion ?? null, + replicationStatus: + response.ReplicationStatus?.map((r) => ({ + region: r.Region ?? '', + kmsKeyId: r.KmsKeyId ?? null, + status: r.Status ?? null, + statusMessage: r.StatusMessage ?? null, + lastAccessedDate: r.LastAccessedDate?.toISOString() ?? null, + })) ?? [], + } +} + +export async function tagResource(client: SecretsManagerClient, secretId: string, tags: Tag[]) { + const command = new TagResourceCommand({ SecretId: secretId, Tags: tags }) + await client.send(command) + return { name: secretId } +} + +export async function untagResource( + client: SecretsManagerClient, + secretId: string, + tagKeys: string[] +) { + const command = new UntagResourceCommand({ SecretId: secretId, TagKeys: tagKeys }) + await client.send(command) + return { name: secretId } +} + +export async function restoreSecret(client: SecretsManagerClient, secretId: string) { + const command = new RestoreSecretCommand({ SecretId: secretId }) + const response = await client.send(command) + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + } +} + +export async function rotateSecret( + client: SecretsManagerClient, + secretId: string, + clientRequestToken?: string | null, + rotationLambdaARN?: string | null, + rotationRules?: { + automaticallyAfterDays?: number | null + duration?: string | null + scheduleExpression?: string | null + } | null, + rotateImmediately?: boolean | null +) { + const hasRotationRules = Boolean( + rotationRules?.automaticallyAfterDays || + rotationRules?.duration || + rotationRules?.scheduleExpression + ) + + const command = new RotateSecretCommand({ + SecretId: secretId, + ...(clientRequestToken ? { ClientRequestToken: clientRequestToken } : {}), + ...(rotationLambdaARN ? { RotationLambdaARN: rotationLambdaARN } : {}), + ...(hasRotationRules + ? { + RotationRules: { + ...(rotationRules?.automaticallyAfterDays + ? { AutomaticallyAfterDays: rotationRules.automaticallyAfterDays } + : {}), + ...(rotationRules?.duration ? { Duration: rotationRules.duration } : {}), + ...(rotationRules?.scheduleExpression + ? { ScheduleExpression: rotationRules.scheduleExpression } + : {}), + }, + } + : {}), + ...(rotateImmediately === undefined || rotateImmediately === null + ? {} + : { RotateImmediately: rotateImmediately }), + }) + + const response = await client.send(command) + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + versionId: response.VersionId ?? '', + } +} diff --git a/apps/sim/app/api/tools/ses/create-configuration-set/route.ts b/apps/sim/app/api/tools/ses/create-configuration-set/route.ts new file mode 100644 index 00000000000..f905aff437f --- /dev/null +++ b/apps/sim/app/api/tools/ses/create-configuration-set/route.ts @@ -0,0 +1,69 @@ +import type { SuppressionListReason } from '@aws-sdk/client-sesv2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesCreateConfigurationSetContract } from '@/lib/api/contracts/tools/aws/ses-create-configuration-set' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createConfigurationSet, createSESClient } from '../utils' + +const logger = createLogger('SESCreateConfigurationSetAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesCreateConfigurationSetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Creating SES configuration set') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const suppressedReasons = params.suppressedReasons + ? (params.suppressedReasons + .split(',') + .map((r) => r.trim()) + .filter(Boolean) as SuppressionListReason[]) + : null + + const result = await createConfigurationSet(client, { + configurationSetName: params.configurationSetName, + customRedirectDomain: params.customRedirectDomain, + httpsPolicy: params.httpsPolicy, + tlsPolicy: params.tlsPolicy, + sendingPoolName: params.sendingPoolName, + reputationMetricsEnabled: params.reputationMetricsEnabled, + sendingEnabled: params.sendingEnabled, + suppressedReasons, + tags: params.tags, + }) + + logger.info(`Created configuration set '${params.configurationSetName}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to create configuration set:', error) + + return NextResponse.json( + { error: `Failed to create configuration set: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/create-email-identity/route.ts b/apps/sim/app/api/tools/ses/create-email-identity/route.ts new file mode 100644 index 00000000000..9f58228b173 --- /dev/null +++ b/apps/sim/app/api/tools/ses/create-email-identity/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesCreateEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-create-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createEmailIdentity, createSESClient } from '../utils' + +const logger = createLogger('SESCreateEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesCreateEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Creating SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await createEmailIdentity(client, { + emailIdentity: params.emailIdentity, + dkimSigningAttributes: params.dkimSigningAttributes, + tags: params.tags, + configurationSetName: params.configurationSetName, + }) + + logger.info(`Created email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to create email identity:', error) + + return NextResponse.json( + { error: `Failed to create email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/delete-email-identity/route.ts b/apps/sim/app/api/tools/ses/delete-email-identity/route.ts new file mode 100644 index 00000000000..a185fc769ca --- /dev/null +++ b/apps/sim/app/api/tools/ses/delete-email-identity/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesDeleteEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-delete-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, deleteEmailIdentity } from '../utils' + +const logger = createLogger('SESDeleteEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesDeleteEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Deleting SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await deleteEmailIdentity(client, params.emailIdentity) + + logger.info(`Deleted email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to delete email identity:', error) + + return NextResponse.json( + { error: `Failed to delete email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts new file mode 100644 index 00000000000..c0a047279e6 --- /dev/null +++ b/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesDeleteSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-delete-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, deleteSuppressedDestination } from '../utils' + +const logger = createLogger('SESDeleteSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesDeleteSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Removing email address from SES suppression list') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await deleteSuppressedDestination(client, params.emailAddress) + + logger.info('Removed email address from suppression list') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to remove suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to remove suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/get-email-identity/route.ts b/apps/sim/app/api/tools/ses/get-email-identity/route.ts new file mode 100644 index 00000000000..c13a11c6fe9 --- /dev/null +++ b/apps/sim/app/api/tools/ses/get-email-identity/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesGetEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-get-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, getEmailIdentity } from '../utils' + +const logger = createLogger('SESGetEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesGetEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Fetching SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await getEmailIdentity(client, params.emailIdentity) + + logger.info(`Fetched email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to get email identity:', error) + + return NextResponse.json( + { error: `Failed to get email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts new file mode 100644 index 00000000000..71022496733 --- /dev/null +++ b/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesGetSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-get-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, getSuppressedDestination } from '../utils' + +const logger = createLogger('SESGetSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesGetSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Fetching SES suppressed destination') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await getSuppressedDestination(client, params.emailAddress) + + logger.info('Fetched suppressed destination') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to get suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to get suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts b/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts new file mode 100644 index 00000000000..639704da110 --- /dev/null +++ b/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts @@ -0,0 +1,80 @@ +import type { SuppressionListReason } from '@aws-sdk/client-sesv2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesListSuppressedDestinationsContract } from '@/lib/api/contracts/tools/aws/ses-list-suppressed-destinations' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, listSuppressedDestinations } from '../utils' + +const logger = createLogger('SESListSuppressedDestinationsAPI') + +const VALID_SUPPRESSION_REASONS: SuppressionListReason[] = ['BOUNCE', 'COMPLAINT'] + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesListSuppressedDestinationsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + let reasons: SuppressionListReason[] | null = null + if (params.reasons) { + const candidates = params.reasons + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + const invalid = candidates.filter( + (r) => !VALID_SUPPRESSION_REASONS.includes(r as SuppressionListReason) + ) + if (invalid.length > 0) { + return NextResponse.json( + { + error: `Invalid suppression reason(s): ${invalid.join(', ')}. Must be one of: ${VALID_SUPPRESSION_REASONS.join(', ')}`, + }, + { status: 400 } + ) + } + reasons = candidates as SuppressionListReason[] + } + + logger.info('Listing SES suppressed destinations') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await listSuppressedDestinations(client, { + reasons, + startDate: params.startDate ? new Date(params.startDate) : null, + endDate: params.endDate ? new Date(params.endDate) : null, + pageSize: params.pageSize, + nextToken: params.nextToken, + }) + + logger.info(`Listed ${result.count} suppressed destinations`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to list suppressed destinations:', error) + + return NextResponse.json( + { error: `Failed to list suppressed destinations: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts new file mode 100644 index 00000000000..453ba387893 --- /dev/null +++ b/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts @@ -0,0 +1,54 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesPutSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-put-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, putSuppressedDestination } from '../utils' + +const logger = createLogger('SESPutSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesPutSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Adding email address to SES suppression list') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await putSuppressedDestination(client, { + emailAddress: params.emailAddress, + reason: params.reason, + }) + + logger.info('Added email address to suppression list') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to add suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to add suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts b/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts new file mode 100644 index 00000000000..dd4bf5387f9 --- /dev/null +++ b/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesSendCustomVerificationEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-custom-verification-email' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, sendCustomVerificationEmail } from '../utils' + +const logger = createLogger('SESSendCustomVerificationEmailAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesSendCustomVerificationEmailContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Sending SES custom verification email') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await sendCustomVerificationEmail(client, { + emailAddress: params.emailAddress, + templateName: params.templateName, + configurationSetName: params.configurationSetName, + }) + + logger.info(`Sent custom verification email to '${params.emailAddress}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to send custom verification email:', error) + + return NextResponse.json( + { error: `Failed to send custom verification email: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/update-template/route.ts b/apps/sim/app/api/tools/ses/update-template/route.ts new file mode 100644 index 00000000000..4bebcff2100 --- /dev/null +++ b/apps/sim/app/api/tools/ses/update-template/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesUpdateTemplateContract } from '@/lib/api/contracts/tools/aws/ses-update-template' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, updateTemplate } from '../utils' + +const logger = createLogger('SESUpdateTemplateAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesUpdateTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Updating SES email template') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await updateTemplate(client, { + templateName: params.templateName, + subjectPart: params.subjectPart, + textPart: params.textPart, + htmlPart: params.htmlPart, + }) + + logger.info(`Updated template '${params.templateName}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to update template:', error) + + return NextResponse.json( + { error: `Failed to update template: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/utils.ts b/apps/sim/app/api/tools/ses/utils.ts index 8e15e2d3d84..62fec2b74a0 100644 --- a/apps/sim/app/api/tools/ses/utils.ts +++ b/apps/sim/app/api/tools/ses/utils.ts @@ -1,13 +1,25 @@ import { + CreateConfigurationSetCommand, + CreateEmailIdentityCommand, CreateEmailTemplateCommand, + DeleteEmailIdentityCommand, DeleteEmailTemplateCommand, + DeleteSuppressedDestinationCommand, GetAccountCommand, + GetEmailIdentityCommand, GetEmailTemplateCommand, + GetSuppressedDestinationCommand, ListEmailIdentitiesCommand, ListEmailTemplatesCommand, + ListSuppressedDestinationsCommand, + PutSuppressedDestinationCommand, SESv2Client, SendBulkEmailCommand, + SendCustomVerificationEmailCommand, SendEmailCommand, + type SuppressionListReason, + type TlsPolicy, + UpdateEmailTemplateCommand, } from '@aws-sdk/client-sesv2' import { z } from 'zod' import type { SESConnectionConfig } from '@/tools/ses/types' @@ -268,3 +280,284 @@ export async function deleteTemplate(client: SESv2Client, templateName: string) message: `Template '${templateName}' deleted successfully`, } } + +export async function updateTemplate( + client: SESv2Client, + params: { + templateName: string + subjectPart: string + textPart?: string | null + htmlPart?: string | null + } +) { + const command = new UpdateEmailTemplateCommand({ + TemplateName: params.templateName, + TemplateContent: { + Subject: params.subjectPart, + ...(params.textPart ? { Text: params.textPart } : {}), + ...(params.htmlPart ? { Html: params.htmlPart } : {}), + }, + }) + + await client.send(command) + + return { + message: `Template '${params.templateName}' updated successfully`, + } +} + +export async function putSuppressedDestination( + client: SESv2Client, + params: { emailAddress: string; reason: SuppressionListReason } +) { + const command = new PutSuppressedDestinationCommand({ + EmailAddress: params.emailAddress, + Reason: params.reason, + }) + + await client.send(command) + + return { + message: `Email address '${params.emailAddress}' added to the suppression list`, + } +} + +export async function deleteSuppressedDestination(client: SESv2Client, emailAddress: string) { + const command = new DeleteSuppressedDestinationCommand({ EmailAddress: emailAddress }) + await client.send(command) + + return { + message: `Email address '${emailAddress}' removed from the suppression list`, + } +} + +export async function getSuppressedDestination(client: SESv2Client, emailAddress: string) { + const command = new GetSuppressedDestinationCommand({ EmailAddress: emailAddress }) + const response = await client.send(command) + const destination = response.SuppressedDestination + + return { + emailAddress: destination?.EmailAddress ?? emailAddress, + reason: destination?.Reason ?? '', + lastUpdateTime: destination?.LastUpdateTime?.toISOString() ?? null, + messageId: destination?.Attributes?.MessageId ?? null, + feedbackId: destination?.Attributes?.FeedbackId ?? null, + } +} + +export async function listSuppressedDestinations( + client: SESv2Client, + params: { + reasons?: SuppressionListReason[] | null + startDate?: Date | null + endDate?: Date | null + pageSize?: number | null + nextToken?: string | null + } +) { + const command = new ListSuppressedDestinationsCommand({ + ...(params.reasons?.length ? { Reasons: params.reasons } : {}), + ...(params.startDate ? { StartDate: params.startDate } : {}), + ...(params.endDate ? { EndDate: params.endDate } : {}), + ...(params.pageSize != null ? { PageSize: params.pageSize } : {}), + ...(params.nextToken ? { NextToken: params.nextToken } : {}), + }) + + const response = await client.send(command) + + const destinations = (response.SuppressedDestinationSummaries ?? []).map((d) => ({ + emailAddress: d.EmailAddress ?? '', + reason: d.Reason ?? '', + lastUpdateTime: d.LastUpdateTime?.toISOString() ?? null, + })) + + return { + destinations, + nextToken: response.NextToken ?? null, + count: destinations.length, + } +} + +export async function createEmailIdentity( + client: SESv2Client, + params: { + emailIdentity: string + dkimSigningAttributes?: { + domainSigningSelector?: string + domainSigningPrivateKey?: string + nextSigningKeyLength?: 'RSA_1024_BIT' | 'RSA_2048_BIT' + } | null + tags?: Array<{ key: string; value: string }> | null + configurationSetName?: string | null + } +) { + const command = new CreateEmailIdentityCommand({ + EmailIdentity: params.emailIdentity, + ...(params.dkimSigningAttributes + ? { + DkimSigningAttributes: { + ...(params.dkimSigningAttributes.domainSigningSelector + ? { DomainSigningSelector: params.dkimSigningAttributes.domainSigningSelector } + : {}), + ...(params.dkimSigningAttributes.domainSigningPrivateKey + ? { DomainSigningPrivateKey: params.dkimSigningAttributes.domainSigningPrivateKey } + : {}), + ...(params.dkimSigningAttributes.nextSigningKeyLength + ? { NextSigningKeyLength: params.dkimSigningAttributes.nextSigningKeyLength } + : {}), + }, + } + : {}), + ...(params.tags?.length + ? { Tags: params.tags.map((t) => ({ Key: t.key, Value: t.value })) } + : {}), + ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + }) + + const response = await client.send(command) + + return { + identityType: response.IdentityType ?? '', + verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false, + dkimAttributes: response.DkimAttributes + ? { + signingEnabled: response.DkimAttributes.SigningEnabled ?? null, + status: response.DkimAttributes.Status ?? null, + tokens: response.DkimAttributes.Tokens ?? [], + signingAttributesOrigin: response.DkimAttributes.SigningAttributesOrigin ?? null, + nextSigningKeyLength: response.DkimAttributes.NextSigningKeyLength ?? null, + currentSigningKeyLength: response.DkimAttributes.CurrentSigningKeyLength ?? null, + lastKeyGenerationTimestamp: + response.DkimAttributes.LastKeyGenerationTimestamp?.toISOString() ?? null, + signingHostedZone: response.DkimAttributes.SigningHostedZone ?? null, + } + : null, + } +} + +export async function deleteEmailIdentity(client: SESv2Client, emailIdentity: string) { + const command = new DeleteEmailIdentityCommand({ EmailIdentity: emailIdentity }) + await client.send(command) + + return { + message: `Email identity '${emailIdentity}' deleted successfully`, + } +} + +export async function getEmailIdentity(client: SESv2Client, emailIdentity: string) { + const command = new GetEmailIdentityCommand({ EmailIdentity: emailIdentity }) + const response = await client.send(command) + + return { + identityType: response.IdentityType ?? '', + verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false, + verificationStatus: response.VerificationStatus ?? null, + feedbackForwardingStatus: response.FeedbackForwardingStatus ?? null, + configurationSetName: response.ConfigurationSetName ?? null, + dkimAttributes: response.DkimAttributes + ? { + signingEnabled: response.DkimAttributes.SigningEnabled ?? null, + status: response.DkimAttributes.Status ?? null, + tokens: response.DkimAttributes.Tokens ?? [], + signingAttributesOrigin: response.DkimAttributes.SigningAttributesOrigin ?? null, + nextSigningKeyLength: response.DkimAttributes.NextSigningKeyLength ?? null, + currentSigningKeyLength: response.DkimAttributes.CurrentSigningKeyLength ?? null, + lastKeyGenerationTimestamp: + response.DkimAttributes.LastKeyGenerationTimestamp?.toISOString() ?? null, + signingHostedZone: response.DkimAttributes.SigningHostedZone ?? null, + } + : null, + mailFromAttributes: response.MailFromAttributes + ? { + mailFromDomain: response.MailFromAttributes.MailFromDomain ?? null, + mailFromDomainStatus: response.MailFromAttributes.MailFromDomainStatus ?? null, + behaviorOnMxFailure: response.MailFromAttributes.BehaviorOnMxFailure ?? null, + } + : null, + policies: response.Policies ?? null, + tags: (response.Tags ?? []).map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })), + verificationInfo: response.VerificationInfo + ? { + errorType: response.VerificationInfo.ErrorType ?? null, + lastCheckedTimestamp: + response.VerificationInfo.LastCheckedTimestamp?.toISOString() ?? null, + lastSuccessTimestamp: + response.VerificationInfo.LastSuccessTimestamp?.toISOString() ?? null, + } + : null, + } +} + +export async function createConfigurationSet( + client: SESv2Client, + params: { + configurationSetName: string + customRedirectDomain?: string | null + httpsPolicy?: 'REQUIRE' | 'REQUIRE_OPEN_ONLY' | 'OPTIONAL' | null + tlsPolicy?: TlsPolicy | null + sendingPoolName?: string | null + reputationMetricsEnabled?: boolean | null + sendingEnabled?: boolean | null + suppressedReasons?: SuppressionListReason[] | null + tags?: Array<{ key: string; value: string }> | null + } +) { + const command = new CreateConfigurationSetCommand({ + ConfigurationSetName: params.configurationSetName, + ...(params.customRedirectDomain + ? { + TrackingOptions: { + CustomRedirectDomain: params.customRedirectDomain, + ...(params.httpsPolicy ? { HttpsPolicy: params.httpsPolicy } : {}), + }, + } + : {}), + ...(params.tlsPolicy || params.sendingPoolName + ? { + DeliveryOptions: { + ...(params.tlsPolicy ? { TlsPolicy: params.tlsPolicy } : {}), + ...(params.sendingPoolName ? { SendingPoolName: params.sendingPoolName } : {}), + }, + } + : {}), + ...(params.reputationMetricsEnabled != null + ? { ReputationOptions: { ReputationMetricsEnabled: params.reputationMetricsEnabled } } + : {}), + ...(params.sendingEnabled != null + ? { SendingOptions: { SendingEnabled: params.sendingEnabled } } + : {}), + ...(params.suppressedReasons?.length + ? { SuppressionOptions: { SuppressedReasons: params.suppressedReasons } } + : {}), + ...(params.tags?.length + ? { Tags: params.tags.map((t) => ({ Key: t.key, Value: t.value })) } + : {}), + }) + + await client.send(command) + + return { + message: `Configuration set '${params.configurationSetName}' created successfully`, + } +} + +export async function sendCustomVerificationEmail( + client: SESv2Client, + params: { + emailAddress: string + templateName: string + configurationSetName?: string | null + } +) { + const command = new SendCustomVerificationEmailCommand({ + EmailAddress: params.emailAddress, + TemplateName: params.templateName, + ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + }) + + const response = await client.send(command) + + return { + messageId: response.MessageId ?? '', + } +} diff --git a/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts b/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts new file mode 100644 index 00000000000..d8762ee4e52 --- /dev/null +++ b/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsStsAssumeRoleWithSAMLContract } from '@/lib/api/contracts/tools/aws/sts-assume-role-with-saml' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assumeRoleWithSAML, createUnauthenticatedSTSClient } from '../utils' + +const logger = createLogger('STSAssumeRoleWithSAMLAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsStsAssumeRoleWithSAMLContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`Assuming role ${params.roleArn} with SAML`) + + const client = createUnauthenticatedSTSClient(params.region) + + try { + const result = await assumeRoleWithSAML( + client, + params.roleArn, + params.principalArn, + params.samlAssertion, + params.policyArns, + params.policy, + params.durationSeconds + ) + + logger.info('Role assumed successfully with SAML') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to assume role with SAML', { error: toError(error).message }) + + return NextResponse.json( + { error: `Failed to assume role with SAML: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts b/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts new file mode 100644 index 00000000000..bd951266bad --- /dev/null +++ b/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsStsAssumeRoleWithWebIdentityContract } from '@/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assumeRoleWithWebIdentity, createUnauthenticatedSTSClient } from '../utils' + +const logger = createLogger('STSAssumeRoleWithWebIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsStsAssumeRoleWithWebIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`Assuming role ${params.roleArn} with web identity`) + + const client = createUnauthenticatedSTSClient(params.region) + + try { + const result = await assumeRoleWithWebIdentity( + client, + params.roleArn, + params.roleSessionName, + params.webIdentityToken, + params.providerId, + params.policyArns, + params.policy, + params.durationSeconds + ) + + logger.info('Role assumed successfully with web identity') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to assume role with web identity', { error: toError(error).message }) + + return NextResponse.json( + { error: `Failed to assume role with web identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sts/assume-role/route.ts b/apps/sim/app/api/tools/sts/assume-role/route.ts index 442250b02ea..a66513a96b9 100644 --- a/apps/sim/app/api/tools/sts/assume-role/route.ts +++ b/apps/sim/app/api/tools/sts/assume-role/route.ts @@ -40,7 +40,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { params.policy, params.externalId, params.serialNumber, - params.tokenCode + params.tokenCode, + params.policyArns, + params.tags, + params.transitiveTagKeys ) logger.info('Role assumed successfully') diff --git a/apps/sim/app/api/tools/sts/utils.ts b/apps/sim/app/api/tools/sts/utils.ts index cf4bee12b68..a0caec02260 100644 --- a/apps/sim/app/api/tools/sts/utils.ts +++ b/apps/sim/app/api/tools/sts/utils.ts @@ -1,9 +1,13 @@ import { AssumeRoleCommand, + AssumeRoleWithSAMLCommand, + AssumeRoleWithWebIdentityCommand, GetAccessKeyInfoCommand, GetCallerIdentityCommand, GetSessionTokenCommand, + type PolicyDescriptorType, STSClient, + type Tag, } from '@aws-sdk/client-sts' import type { STSConnectionConfig } from '@/tools/sts/types' @@ -17,6 +21,52 @@ export function createSTSClient(config: STSConnectionConfig): STSClient { }) } +/** + * Creates an STS client for AssumeRoleWithWebIdentity / AssumeRoleWithSAML, + * which authenticate the caller via the supplied token/assertion rather than + * an IAM access key — AWS does not check the request signature for these two + * operations. The SDK's signing middleware still requires a `credentials` + * value to be resolvable, though, so static placeholder credentials are + * supplied explicitly to skip the default credential provider chain (env + * vars, shared config, container/IMDS role). Without this, the client would + * throw a CredentialsProviderError before the request is even sent in + * environments with no ambient AWS identity, even though a real IAM identity + * was never required. + */ +export function createUnauthenticatedSTSClient(region: string): STSClient { + return new STSClient({ + region, + credentials: { accessKeyId: 'anonymous', secretAccessKey: 'anonymous' }, + }) +} + +function parsePolicyArns(policyArns?: string | null): PolicyDescriptorType[] | undefined { + if (!policyArns) return undefined + const arns = policyArns + .split(',') + .map((arn) => arn.trim()) + .filter((arn) => arn.length > 0) + return arns.length > 0 ? arns.map((arn) => ({ arn })) : undefined +} + +function parseTags(tags?: string | null): Tag[] | undefined { + if (!tags) return undefined + const parsed = JSON.parse(tags) as Record + const entries = Object.entries(parsed) + return entries.length > 0 + ? entries.map(([Key, Value]) => ({ Key, Value: String(Value) })) + : undefined +} + +function parseTransitiveTagKeys(transitiveTagKeys?: string | null): string[] | undefined { + if (!transitiveTagKeys) return undefined + const keys = transitiveTagKeys + .split(',') + .map((key) => key.trim()) + .filter((key) => key.length > 0) + return keys.length > 0 ? keys : undefined +} + export async function assumeRole( client: STSClient, roleArn: string, @@ -25,7 +75,10 @@ export async function assumeRole( policy?: string | null, externalId?: string | null, serialNumber?: string | null, - tokenCode?: string | null + tokenCode?: string | null, + policyArns?: string | null, + tags?: string | null, + transitiveTagKeys?: string | null ) { const command = new AssumeRoleCommand({ RoleArn: roleArn, @@ -35,6 +88,93 @@ export async function assumeRole( ...(externalId ? { ExternalId: externalId } : {}), ...(serialNumber ? { SerialNumber: serialNumber } : {}), ...(tokenCode ? { TokenCode: tokenCode } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), + ...(() => { + const sessionTags = parseTags(tags) + return sessionTags ? { Tags: sessionTags } : {} + })(), + ...(() => { + const keys = parseTransitiveTagKeys(transitiveTagKeys) + return keys ? { TransitiveTagKeys: keys } : {} + })(), + }) + + const response = await client.send(command) + + return { + accessKeyId: response.Credentials?.AccessKeyId ?? '', + secretAccessKey: response.Credentials?.SecretAccessKey ?? '', + sessionToken: response.Credentials?.SessionToken ?? '', + expiration: response.Credentials?.Expiration?.toISOString() ?? null, + assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', + assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + packedPolicySize: response.PackedPolicySize ?? null, + sourceIdentity: response.SourceIdentity ?? null, + } +} + +export async function assumeRoleWithWebIdentity( + client: STSClient, + roleArn: string, + roleSessionName: string, + webIdentityToken: string, + providerId?: string | null, + policyArns?: string | null, + policy?: string | null, + durationSeconds?: number | null +) { + const command = new AssumeRoleWithWebIdentityCommand({ + RoleArn: roleArn, + RoleSessionName: roleSessionName, + WebIdentityToken: webIdentityToken, + ...(providerId ? { ProviderId: providerId } : {}), + ...(policy ? { Policy: policy } : {}), + ...(durationSeconds ? { DurationSeconds: durationSeconds } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), + }) + + const response = await client.send(command) + + return { + accessKeyId: response.Credentials?.AccessKeyId ?? '', + secretAccessKey: response.Credentials?.SecretAccessKey ?? '', + sessionToken: response.Credentials?.SessionToken ?? '', + expiration: response.Credentials?.Expiration?.toISOString() ?? null, + assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', + assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + subjectFromWebIdentityToken: response.SubjectFromWebIdentityToken ?? '', + audience: response.Audience ?? null, + provider: response.Provider ?? null, + packedPolicySize: response.PackedPolicySize ?? null, + sourceIdentity: response.SourceIdentity ?? null, + } +} + +export async function assumeRoleWithSAML( + client: STSClient, + roleArn: string, + principalArn: string, + samlAssertion: string, + policyArns?: string | null, + policy?: string | null, + durationSeconds?: number | null +) { + const command = new AssumeRoleWithSAMLCommand({ + RoleArn: roleArn, + PrincipalArn: principalArn, + SAMLAssertion: samlAssertion, + ...(policy ? { Policy: policy } : {}), + ...(durationSeconds ? { DurationSeconds: durationSeconds } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), }) const response = await client.send(command) @@ -46,6 +186,11 @@ export async function assumeRole( expiration: response.Credentials?.Expiration?.toISOString() ?? null, assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + subject: response.Subject ?? null, + subjectType: response.SubjectType ?? null, + issuer: response.Issuer ?? null, + audience: response.Audience ?? null, + nameQualifier: response.NameQualifier ?? null, packedPolicySize: response.PackedPolicySize ?? null, sourceIdentity: response.SourceIdentity ?? null, } diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index ccf2c4ae230..19fda449390 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -8,7 +8,7 @@ export const SecretsManagerBlock: BlockConfig = { name: 'AWS Secrets Manager', description: 'Connect to AWS Secrets Manager', longDescription: - 'Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.', + 'Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, delete, describe, tag, untag, restore, and rotate secrets.', docsLink: 'https://docs.sim.ai/integrations/secrets_manager', category: 'tools', integrationType: IntegrationType.Security, @@ -25,6 +25,11 @@ export const SecretsManagerBlock: BlockConfig = { { label: 'Create Secret', id: 'create_secret' }, { label: 'Update Secret', id: 'update_secret' }, { label: 'Delete Secret', id: 'delete_secret' }, + { label: 'Describe Secret', id: 'describe_secret' }, + { label: 'Tag Secret', id: 'tag_resource' }, + { label: 'Untag Secret', id: 'untag_resource' }, + { label: 'Restore Secret', id: 'restore_secret' }, + { label: 'Rotate Secret', id: 'rotate_secret' }, ], value: () => 'get_secret', }, @@ -56,8 +61,32 @@ export const SecretsManagerBlock: BlockConfig = { title: 'Secret Name or ARN', type: 'short-input', placeholder: 'my-app/database-password', - condition: { field: 'operation', value: ['get_secret', 'update_secret', 'delete_secret'] }, - required: { field: 'operation', value: ['get_secret', 'update_secret', 'delete_secret'] }, + condition: { + field: 'operation', + value: [ + 'get_secret', + 'update_secret', + 'delete_secret', + 'describe_secret', + 'tag_resource', + 'untag_resource', + 'restore_secret', + 'rotate_secret', + ], + }, + required: { + field: 'operation', + value: [ + 'get_secret', + 'update_secret', + 'delete_secret', + 'describe_secret', + 'tag_resource', + 'untag_resource', + 'restore_secret', + 'rotate_secret', + ], + }, }, { id: 'name', @@ -142,6 +171,81 @@ export const SecretsManagerBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'tags', + title: 'Tags', + type: 'code', + placeholder: '[{"key":"env","value":"prod"}]', + condition: { field: 'operation', value: 'tag_resource' }, + required: { field: 'operation', value: 'tag_resource' }, + }, + { + id: 'tagKeys', + title: 'Tag Keys', + type: 'code', + placeholder: '["env","team"]', + condition: { field: 'operation', value: 'untag_resource' }, + required: { field: 'operation', value: 'untag_resource' }, + }, + { + id: 'rotationLambdaARN', + title: 'Rotation Lambda ARN', + type: 'short-input', + placeholder: + 'arn:aws:lambda:us-east-1:123456789012:function:my-rotation-fn (omit for managed rotation)', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'automaticallyAfterDays', + title: 'Automatically After Days', + type: 'short-input', + placeholder: '30', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'duration', + title: 'Rotation Window Duration', + type: 'short-input', + placeholder: '3h', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'scheduleExpression', + title: 'Schedule Expression', + type: 'short-input', + placeholder: 'cron(0 16 1,15 * ? *) or rate(10 days)', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'rotateImmediately', + title: 'Rotate Immediately', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'clientRequestToken', + title: 'Client Request Token', + type: 'short-input', + placeholder: 'Idempotency token (32-64 chars, optional)', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, ], tools: { access: [ @@ -150,6 +254,11 @@ export const SecretsManagerBlock: BlockConfig = { 'secrets_manager_create_secret', 'secrets_manager_update_secret', 'secrets_manager_delete_secret', + 'secrets_manager_describe_secret', + 'secrets_manager_tag_resource', + 'secrets_manager_untag_resource', + 'secrets_manager_restore_secret', + 'secrets_manager_rotate_secret', ], config: { tool: (params) => { @@ -164,12 +273,30 @@ export const SecretsManagerBlock: BlockConfig = { return 'secrets_manager_update_secret' case 'delete_secret': return 'secrets_manager_delete_secret' + case 'describe_secret': + return 'secrets_manager_describe_secret' + case 'tag_resource': + return 'secrets_manager_tag_resource' + case 'untag_resource': + return 'secrets_manager_untag_resource' + case 'restore_secret': + return 'secrets_manager_restore_secret' + case 'rotate_secret': + return 'secrets_manager_rotate_secret' default: throw new Error(`Invalid Secrets Manager operation: ${params.operation}`) } }, params: (params) => { - const { operation, forceDelete, recoveryWindowInDays, maxResults, ...rest } = params + const { + operation, + forceDelete, + recoveryWindowInDays, + maxResults, + automaticallyAfterDays, + rotateImmediately, + ...rest + } = params const connectionConfig = { region: rest.region, @@ -210,6 +337,37 @@ export const SecretsManagerBlock: BlockConfig = { } if (forceDelete === 'true' || forceDelete === true) result.forceDelete = true break + case 'describe_secret': + result.secretId = rest.secretId + break + case 'tag_resource': + result.secretId = rest.secretId + result.tags = typeof rest.tags === 'string' ? JSON.parse(rest.tags) : rest.tags + break + case 'untag_resource': + result.secretId = rest.secretId + result.tagKeys = + typeof rest.tagKeys === 'string' ? JSON.parse(rest.tagKeys) : rest.tagKeys + break + case 'restore_secret': + result.secretId = rest.secretId + break + case 'rotate_secret': + result.secretId = rest.secretId + if (rest.clientRequestToken) result.clientRequestToken = rest.clientRequestToken + if (rest.rotationLambdaARN) result.rotationLambdaARN = rest.rotationLambdaARN + if (automaticallyAfterDays) { + const parsed = Number.parseInt(String(automaticallyAfterDays), 10) + if (!Number.isNaN(parsed)) result.automaticallyAfterDays = parsed + } + if (rest.duration) result.duration = rest.duration + if (rest.scheduleExpression) result.scheduleExpression = rest.scheduleExpression + if (rotateImmediately === 'false' || rotateImmediately === false) { + result.rotateImmediately = false + } else if (rotateImmediately === 'true' || rotateImmediately === true) { + result.rotateImmediately = true + } + break } return result @@ -231,6 +389,14 @@ export const SecretsManagerBlock: BlockConfig = { nextToken: { type: 'string', description: 'Pagination token' }, recoveryWindowInDays: { type: 'number', description: 'Days before permanent deletion' }, forceDelete: { type: 'string', description: 'Force immediate deletion' }, + tags: { type: 'json', description: 'Tags to attach, as an array of {key, value} pairs' }, + tagKeys: { type: 'json', description: 'Tag keys to remove, as an array of strings' }, + rotationLambdaARN: { type: 'string', description: 'ARN of the Lambda rotation function' }, + automaticallyAfterDays: { type: 'number', description: 'Days between automatic rotations' }, + duration: { type: 'string', description: 'Rotation window duration (e.g., 3h)' }, + scheduleExpression: { type: 'string', description: 'cron() or rate() rotation schedule' }, + rotateImmediately: { type: 'string', description: 'Whether to rotate immediately' }, + clientRequestToken: { type: 'string', description: 'Idempotency token for rotation' }, }, outputs: { message: { @@ -277,6 +443,55 @@ export const SecretsManagerBlock: BlockConfig = { type: 'string', description: 'Scheduled deletion date', }, + description: { + type: 'string', + description: 'Description of the secret', + }, + kmsKeyId: { + type: 'string', + description: 'KMS key ID used to encrypt the secret', + }, + rotationEnabled: { + type: 'boolean', + description: 'Whether automatic rotation is enabled', + }, + rotationLambdaARN: { + type: 'string', + description: 'ARN of the Lambda function used for rotation', + }, + rotationRules: { + type: 'json', + description: + 'Rotation schedule configuration (automaticallyAfterDays, duration, scheduleExpression)', + }, + lastRotatedDate: { + type: 'string', + description: 'Date the secret was last rotated', + }, + nextRotationDate: { + type: 'string', + description: 'Date the secret is next scheduled to rotate', + }, + deletedDate: { + type: 'string', + description: 'Date the secret is scheduled for deletion, if any', + }, + tags: { + type: 'json', + description: 'Tags attached to the secret', + }, + owningService: { + type: 'string', + description: 'ID of the AWS service that manages this secret, if any', + }, + primaryRegion: { + type: 'string', + description: 'The primary region of the secret, if replicated', + }, + replicationStatus: { + type: 'json', + description: 'Replication status for each region the secret is replicated to', + }, }, } @@ -353,6 +568,15 @@ export const SecretsManagerBlockMeta = { tags: ['devops', 'monitoring'], alsoIntegrations: ['slack'], }, + { + icon: SecretsManagerIcon, + title: 'Secrets Manager native rotation kickoff', + prompt: + 'Build a scheduled workflow that describes AWS Secrets Manager secrets nearing their next rotation date, starts native rotation for the ones that are due, and writes the outcome to a compliance table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'enterprise'], + }, ], skills: [ { @@ -383,5 +607,26 @@ export const SecretsManagerBlockMeta = { content: '# Decommission Secret\n\nRetire a secret without permanently losing it immediately.\n\n## Steps\n1. Confirm the target secret and that nothing still depends on it.\n2. Schedule deletion with a recovery window (e.g. 30 days) so it can be restored during that period; reserve force delete for confirmed-orphaned secrets only.\n3. Record the scheduled deletion date.\n4. Re-list secrets to confirm it is marked for deletion.\n\n## Output\nConfirm the secret name and the scheduled deletion date, or that immediate deletion was requested. Do not print the secret value.', }, + { + name: 'start-native-rotation', + description: + 'Start or reconfigure AWS Secrets Manager native rotation for a secret that already has a rotation Lambda configured. Use when compliance requires automatic, scheduled rotation rather than manual updates.', + content: + '# Start Native Rotation\n\nConfigure or trigger built-in Secrets Manager rotation.\n\n## Steps\n1. Describe the target secret to confirm it has a rotation Lambda ARN configured (or supply one).\n2. Decide the rotation schedule: a fixed interval in days, or a cron/rate schedule expression, plus an optional rotation window duration.\n3. Start rotation with the chosen schedule; rotation runs immediately unless configured to wait for the next window.\n4. Re-describe the secret afterward to confirm the next rotation date and rotation-enabled status.\n\n## Output\nConfirm the secret name, whether rotation is now enabled, and the next scheduled rotation date. Never print the secret value.', + }, + { + name: 'restore-deleted-secret', + description: + 'Cancel a scheduled deletion in AWS Secrets Manager to restore access to a secret before its recovery window elapses. Use to reverse an accidental or premature delete.', + content: + '# Restore Deleted Secret\n\nUndo a pending deletion while the recovery window is still open.\n\n## Steps\n1. Describe the secret to confirm it has a scheduled deletion date.\n2. Restore the secret, which clears the scheduled deletion.\n3. Re-describe the secret to confirm the deletion date is cleared.\n\n## Output\nConfirm the secret name and ARN, and that it is no longer scheduled for deletion.', + }, + { + name: 'tag-secret-for-governance', + description: + 'Attach or remove ownership, environment, or cost-center tags on an AWS Secrets Manager secret for governance and cost allocation. Use to keep secret metadata consistent with tagging policy.', + content: + '# Tag Secret for Governance\n\nKeep secret tags aligned with organizational tagging policy.\n\n## Steps\n1. Describe the secret to see its current tags.\n2. Attach the required tags (e.g. owner, environment, cost-center) as key/value pairs, or remove tags that no longer apply by key.\n3. Re-describe the secret to confirm the tag set matches policy.\n\n## Output\nConfirm the secret name and the resulting tag keys. Do not print the secret value.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/ses.ts b/apps/sim/blocks/blocks/ses.ts index 2f7ad5ff10f..30661319b4c 100644 --- a/apps/sim/blocks/blocks/ses.ts +++ b/apps/sim/blocks/blocks/ses.ts @@ -8,7 +8,7 @@ export const SESBlock: BlockConfig = { name: 'AWS SES', description: 'Send emails and manage templates with AWS Simple Email Service', longDescription: - 'Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information.', + 'Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates, identities, configuration sets, and the account suppression list, and retrieve account sending quota and verified identity information.', docsLink: 'https://docs.sim.ai/integrations/ses', category: 'tools', integrationType: IntegrationType.Email, @@ -30,6 +30,16 @@ export const SESBlock: BlockConfig = { { label: 'Get Template', id: 'get_template' }, { label: 'List Templates', id: 'list_templates' }, { label: 'Delete Template', id: 'delete_template' }, + { label: 'Update Template', id: 'update_template' }, + { label: 'Send Custom Verification Email', id: 'send_custom_verification_email' }, + { label: 'Create Email Identity', id: 'create_email_identity' }, + { label: 'Get Email Identity', id: 'get_email_identity' }, + { label: 'Delete Email Identity', id: 'delete_email_identity' }, + { label: 'Put Suppressed Destination', id: 'put_suppressed_destination' }, + { label: 'Get Suppressed Destination', id: 'get_suppressed_destination' }, + { label: 'List Suppressed Destinations', id: 'list_suppressed_destinations' }, + { label: 'Delete Suppressed Destination', id: 'delete_suppressed_destination' }, + { label: 'Create Configuration Set', id: 'create_configuration_set' }, ], value: () => 'send_email', }, @@ -128,6 +138,8 @@ export const SESBlock: BlockConfig = { 'get_template', 'create_template', 'delete_template', + 'update_template', + 'send_custom_verification_email', ], }, required: { @@ -138,6 +150,8 @@ export const SESBlock: BlockConfig = { 'get_template', 'create_template', 'delete_template', + 'update_template', + 'send_custom_verification_email', ], }, }, @@ -165,15 +179,15 @@ export const SESBlock: BlockConfig = { title: 'Subject', type: 'short-input', placeholder: 'Hello, {{name}}!', - condition: { field: 'operation', value: 'create_template' }, - required: { field: 'operation', value: 'create_template' }, + condition: { field: 'operation', value: ['create_template', 'update_template'] }, + required: { field: 'operation', value: ['create_template', 'update_template'] }, }, { id: 'htmlPart', title: 'HTML Body', type: 'long-input', placeholder: '

Hello, {{name}}!

', - condition: { field: 'operation', value: 'create_template' }, + condition: { field: 'operation', value: ['create_template', 'update_template'] }, required: false, }, { @@ -181,7 +195,7 @@ export const SESBlock: BlockConfig = { title: 'Plain Text Body', type: 'long-input', placeholder: 'Hello, {{name}}!', - condition: { field: 'operation', value: 'create_template' }, + condition: { field: 'operation', value: ['create_template', 'update_template'] }, required: false, mode: 'advanced', }, @@ -235,7 +249,13 @@ export const SESBlock: BlockConfig = { placeholder: 'my-configuration-set', condition: { field: 'operation', - value: ['send_email', 'send_templated_email', 'send_bulk_email'], + value: [ + 'send_email', + 'send_templated_email', + 'send_bulk_email', + 'send_custom_verification_email', + 'create_email_identity', + ], }, required: false, mode: 'advanced', @@ -247,7 +267,7 @@ export const SESBlock: BlockConfig = { placeholder: '100', condition: { field: 'operation', - value: ['list_identities', 'list_templates'], + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], }, required: false, mode: 'advanced', @@ -259,11 +279,198 @@ export const SESBlock: BlockConfig = { placeholder: 'Pagination token from previous response', condition: { field: 'operation', - value: ['list_identities', 'list_templates'], + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'emailAddress', + title: 'Email Address', + type: 'short-input', + placeholder: 'recipient@example.com', + condition: { + field: 'operation', + value: [ + 'put_suppressed_destination', + 'get_suppressed_destination', + 'delete_suppressed_destination', + 'send_custom_verification_email', + ], + }, + required: { + field: 'operation', + value: [ + 'put_suppressed_destination', + 'get_suppressed_destination', + 'delete_suppressed_destination', + 'send_custom_verification_email', + ], + }, + }, + { + id: 'reason', + title: 'Suppression Reason', + type: 'dropdown', + options: [ + { label: 'Bounce', id: 'BOUNCE' }, + { label: 'Complaint', id: 'COMPLAINT' }, + ], + condition: { field: 'operation', value: 'put_suppressed_destination' }, + required: { field: 'operation', value: 'put_suppressed_destination' }, + value: () => 'BOUNCE', + }, + { + id: 'reasons', + title: 'Reasons Filter', + type: 'short-input', + placeholder: 'BOUNCE, COMPLAINT', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + required: false, + mode: 'advanced', + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + }, + { + id: 'emailIdentity', + title: 'Email Identity', + type: 'short-input', + placeholder: 'example.com or sender@example.com', + condition: { + field: 'operation', + value: ['create_email_identity', 'get_email_identity', 'delete_email_identity'], + }, + required: { + field: 'operation', + value: ['create_email_identity', 'get_email_identity', 'delete_email_identity'], + }, + }, + { + id: 'dkimSigningAttributes', + title: 'DKIM Signing Attributes (JSON)', + type: 'code', + language: 'json', + placeholder: + '{"domainSigningSelector": "selector1", "domainSigningPrivateKey": "base64-key"}', + condition: { field: 'operation', value: 'create_email_identity' }, + required: false, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Tags (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"key": "team", "value": "growth"}]', + condition: { + field: 'operation', + value: ['create_email_identity', 'create_configuration_set'], }, required: false, mode: 'advanced', }, + { + id: 'newConfigurationSetName', + title: 'Configuration Set Name', + type: 'short-input', + placeholder: 'my-configuration-set', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: { field: 'operation', value: 'create_configuration_set' }, + }, + { + id: 'customRedirectDomain', + title: 'Custom Redirect Domain', + type: 'short-input', + placeholder: 'links.example.com', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'httpsPolicy', + title: 'HTTPS Policy', + type: 'dropdown', + options: [ + { label: 'Require', id: 'REQUIRE' }, + { label: 'Require Open Only', id: 'REQUIRE_OPEN_ONLY' }, + { label: 'Optional', id: 'OPTIONAL' }, + ], + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'tlsPolicy', + title: 'TLS Policy', + type: 'dropdown', + options: [ + { label: 'Require', id: 'REQUIRE' }, + { label: 'Optional', id: 'OPTIONAL' }, + ], + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'sendingPoolName', + title: 'Dedicated IP Pool', + type: 'short-input', + placeholder: 'my-ip-pool', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'reputationMetricsEnabled', + title: 'Enable Reputation Metrics', + type: 'switch', + condition: { field: 'operation', value: 'create_configuration_set' }, + mode: 'advanced', + }, + { + id: 'sendingEnabled', + title: 'Enable Sending', + type: 'switch', + condition: { field: 'operation', value: 'create_configuration_set' }, + mode: 'advanced', + }, + { + id: 'suppressedReasons', + title: 'Auto-Suppress Reasons', + type: 'short-input', + placeholder: 'BOUNCE, COMPLAINT', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, ], tools: { access: [ @@ -276,6 +483,16 @@ export const SESBlock: BlockConfig = { 'ses_get_template', 'ses_list_templates', 'ses_delete_template', + 'ses_update_template', + 'ses_put_suppressed_destination', + 'ses_delete_suppressed_destination', + 'ses_get_suppressed_destination', + 'ses_list_suppressed_destinations', + 'ses_create_email_identity', + 'ses_delete_email_identity', + 'ses_get_email_identity', + 'ses_create_configuration_set', + 'ses_send_custom_verification_email', ], config: { tool: (params) => { @@ -298,6 +515,26 @@ export const SESBlock: BlockConfig = { return 'ses_list_templates' case 'delete_template': return 'ses_delete_template' + case 'update_template': + return 'ses_update_template' + case 'put_suppressed_destination': + return 'ses_put_suppressed_destination' + case 'delete_suppressed_destination': + return 'ses_delete_suppressed_destination' + case 'get_suppressed_destination': + return 'ses_get_suppressed_destination' + case 'list_suppressed_destinations': + return 'ses_list_suppressed_destinations' + case 'create_email_identity': + return 'ses_create_email_identity' + case 'delete_email_identity': + return 'ses_delete_email_identity' + case 'get_email_identity': + return 'ses_get_email_identity' + case 'create_configuration_set': + return 'ses_create_configuration_set' + case 'send_custom_verification_email': + return 'ses_send_custom_verification_email' default: throw new Error(`Invalid SES operation: ${params.operation}`) } @@ -314,6 +551,60 @@ export const SESBlock: BlockConfig = { const result: Record = { ...connectionConfig } switch (operation) { + case 'update_template': + result.templateName = rest.templateName + result.subjectPart = rest.subjectPart + if (rest.htmlPart) result.htmlPart = rest.htmlPart + if (rest.textPart) result.textPart = rest.textPart + break + case 'put_suppressed_destination': + result.emailAddress = rest.emailAddress + result.reason = rest.reason + break + case 'delete_suppressed_destination': + case 'get_suppressed_destination': + result.emailAddress = rest.emailAddress + break + case 'list_suppressed_destinations': + if (rest.reasons) result.reasons = rest.reasons + if (rest.startDate) result.startDate = rest.startDate + if (rest.endDate) result.endDate = rest.endDate + if (pageSize != null) { + const parsed = Number.parseInt(String(pageSize), 10) + if (!Number.isNaN(parsed)) result.pageSize = parsed + } + if (rest.nextToken) result.nextToken = rest.nextToken + break + case 'create_email_identity': + result.emailIdentity = rest.emailIdentity + if (rest.dkimSigningAttributes) + result.dkimSigningAttributes = rest.dkimSigningAttributes + if (rest.tags) result.tags = rest.tags + if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + break + case 'delete_email_identity': + case 'get_email_identity': + result.emailIdentity = rest.emailIdentity + break + case 'create_configuration_set': + result.configurationSetName = rest.newConfigurationSetName + if (rest.customRedirectDomain) result.customRedirectDomain = rest.customRedirectDomain + if (rest.httpsPolicy) result.httpsPolicy = rest.httpsPolicy + if (rest.tlsPolicy) result.tlsPolicy = rest.tlsPolicy + if (rest.sendingPoolName) result.sendingPoolName = rest.sendingPoolName + if (rest.reputationMetricsEnabled != null) + result.reputationMetricsEnabled = + rest.reputationMetricsEnabled === true || rest.reputationMetricsEnabled === 'true' + if (rest.sendingEnabled != null) + result.sendingEnabled = rest.sendingEnabled === true || rest.sendingEnabled === 'true' + if (rest.suppressedReasons) result.suppressedReasons = rest.suppressedReasons + if (rest.tags) result.tags = rest.tags + break + case 'send_custom_verification_email': + result.emailAddress = rest.emailAddress + result.templateName = rest.templateName + if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + break case 'send_email': result.fromAddress = rest.fromAddress result.toAddresses = rest.toAddresses @@ -407,12 +698,38 @@ export const SESBlock: BlockConfig = { configurationSetName: { type: 'string', description: 'SES configuration set name' }, pageSize: { type: 'number', description: 'Maximum number of results to return' }, nextToken: { type: 'string', description: 'Pagination token from previous response' }, + emailAddress: { + type: 'string', + description: 'Email address for suppression or verification operations', + }, + reason: { type: 'string', description: 'Suppression reason: BOUNCE or COMPLAINT' }, + reasons: { type: 'string', description: 'Comma-separated suppression reasons filter' }, + startDate: { type: 'string', description: 'Suppression list filter start date (ISO 8601)' }, + endDate: { type: 'string', description: 'Suppression list filter end date (ISO 8601)' }, + emailIdentity: { type: 'string', description: 'Email address or domain identity' }, + dkimSigningAttributes: { type: 'json', description: 'JSON BYODKIM signing attributes' }, + tags: { type: 'json', description: 'JSON array of key/value tags' }, + newConfigurationSetName: { type: 'string', description: 'Name for a new configuration set' }, + customRedirectDomain: { type: 'string', description: 'Custom domain for open/click tracking' }, + httpsPolicy: { type: 'string', description: 'HTTPS policy for tracking links' }, + tlsPolicy: { type: 'string', description: 'TLS policy for delivery' }, + sendingPoolName: { type: 'string', description: 'Dedicated IP pool name' }, + reputationMetricsEnabled: { + type: 'boolean', + description: 'Whether to collect reputation metrics', + }, + sendingEnabled: { type: 'boolean', description: 'Whether sending is enabled' }, + suppressedReasons: { type: 'string', description: 'Comma-separated auto-suppression reasons' }, }, outputs: { messageId: { type: 'string', - description: 'SES message ID (send_email, send_templated_email)', - condition: { field: 'operation', value: ['send_email', 'send_templated_email'] }, + description: + 'SES message ID (send_email, send_templated_email, send_custom_verification_email)', + condition: { + field: 'operation', + value: ['send_email', 'send_templated_email', 'send_custom_verification_email'], + }, }, results: { type: 'array', @@ -436,13 +753,89 @@ export const SESBlock: BlockConfig = { }, nextToken: { type: 'string', - description: 'Pagination token for the next page (list_identities, list_templates)', - condition: { field: 'operation', value: ['list_identities', 'list_templates'] }, + description: + 'Pagination token for the next page (list_identities, list_templates, list_suppressed_destinations)', + condition: { + field: 'operation', + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], + }, }, count: { type: 'number', - description: 'Number of items returned (list_identities, list_templates)', - condition: { field: 'operation', value: ['list_identities', 'list_templates'] }, + description: + 'Number of items returned (list_identities, list_templates, list_suppressed_destinations)', + condition: { + field: 'operation', + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], + }, + }, + destinations: { + type: 'array', + description: 'List of suppressed destinations (list_suppressed_destinations)', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + }, + emailAddress: { + type: 'string', + description: 'The suppressed email address (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + reason: { + type: 'string', + description: 'The suppression reason (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + lastUpdateTime: { + type: 'string', + description: + 'When the address was added to the suppression list (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + feedbackId: { + type: 'string', + description: 'Feedback ID for the bounce/complaint event (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + identityType: { + type: 'string', + description: + 'Identity type: EMAIL_ADDRESS or DOMAIN (create_email_identity, get_email_identity)', + condition: { field: 'operation', value: ['create_email_identity', 'get_email_identity'] }, + }, + verifiedForSendingStatus: { + type: 'boolean', + description: + 'Whether the identity is verified for sending (create_email_identity, get_email_identity)', + condition: { field: 'operation', value: ['create_email_identity', 'get_email_identity'] }, + }, + dkimAttributes: { + type: 'json', + description: 'DKIM signing status and tokens (create_email_identity, get_email_identity)', + condition: { field: 'operation', value: ['create_email_identity', 'get_email_identity'] }, + }, + verificationStatus: { + type: 'string', + description: 'Identity verification status (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + feedbackForwardingStatus: { + type: 'boolean', + description: 'Whether bounce/complaint feedback is forwarded by email (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + mailFromAttributes: { + type: 'json', + description: 'Custom MAIL FROM domain configuration (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + policies: { + type: 'json', + description: 'Sending authorization policies (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + verificationInfo: { + type: 'json', + description: 'Additional verification diagnostics (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, }, sendingEnabled: { type: 'boolean', @@ -489,10 +882,27 @@ export const SESBlock: BlockConfig = { description: 'List of email templates (list_templates)', condition: { field: 'operation', value: 'list_templates' }, }, + tags: { + type: 'array', + description: 'Tags associated with the identity (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, message: { type: 'string', - description: 'Confirmation message (create_template, delete_template)', - condition: { field: 'operation', value: ['create_template', 'delete_template'] }, + description: + 'Confirmation message (create_template, delete_template, update_template, put_suppressed_destination, delete_suppressed_destination, delete_email_identity, create_configuration_set)', + condition: { + field: 'operation', + value: [ + 'create_template', + 'delete_template', + 'update_template', + 'put_suppressed_destination', + 'delete_suppressed_destination', + 'delete_email_identity', + 'create_configuration_set', + ], + }, }, }, } @@ -549,6 +959,25 @@ export const SESBlockMeta = { tags: ['support', 'communication', 'automation'], alsoIntegrations: ['agentmail'], }, + { + icon: SESIcon, + title: 'SES suppression list sync', + prompt: + 'Build a workflow that reads bounce and complaint webhook events, adds the affected addresses to the SES account suppression list with the matching reason, and periodically lists suppressed destinations to reconcile a marketing contacts table so unreachable addresses are excluded from future sends.', + modules: ['tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation', 'analysis'], + }, + { + icon: SESIcon, + title: 'SES new-domain onboarding', + prompt: + 'Create a workflow that takes a new sending domain from a table, creates the SES email identity, polls get email identity until DKIM verification succeeds, creates a dedicated configuration set with open and click tracking enabled, and posts the DNS tokens to Slack for the infrastructure team to add.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'infrastructure', 'automation'], + alsoIntegrations: ['slack'], + }, { icon: SESIcon, title: 'SES domain reputation monitor', @@ -586,9 +1015,23 @@ export const SESBlockMeta = { { name: 'manage-email-templates', description: - 'Create, fetch, list, and delete reusable email templates in AWS SES. Use to maintain a consistent, version-controlled template library.', + 'Create, fetch, list, update, and delete reusable email templates in AWS SES. Use to maintain a consistent, version-controlled template library.', + content: + '# Manage Email Templates\n\nMaintain the SES template library.\n\n## Steps\n1. To add a template, create it with a name, subject, and HTML and text parts using placeholder variables.\n2. To review, get a template by name or list templates.\n3. To revise copy without changing the name, update the template with new subject, HTML, or text content.\n4. To retire one, delete the template by name.\n5. Keep template names descriptive so they are easy to reference when sending.\n\n## Output\nReport the template name affected and the action taken, or the template contents for a fetch.', + }, + { + name: 'manage-suppression-list', + description: + 'Add, remove, look up, and list addresses on the AWS SES account-level suppression list. Use to keep bounced or complained addresses out of future sends.', + content: + '# Manage Suppression List\n\nKeep the SES suppression list accurate so future sends skip unreachable or unwilling recipients.\n\n## Steps\n1. To suppress an address after a bounce or complaint, put a suppressed destination with the matching reason (BOUNCE or COMPLAINT).\n2. To check whether an address is already suppressed, get the suppressed destination by email address.\n3. To audit the list, list suppressed destinations, optionally filtered by reason or a date range.\n4. To re-enable sending to an address (for example, after a customer confirms a new inbox), delete the suppressed destination.\n\n## Output\nReport the email address affected and the action taken. For a list, report the count of suppressed addresses and their reasons.', + }, + { + name: 'onboard-sending-domain', + description: + 'Verify a new sending domain or address in AWS SES and check DKIM and verification status. Use before sending from a new identity.', content: - '# Manage Email Templates\n\nMaintain the SES template library.\n\n## Steps\n1. To add a template, create it with a name, subject, and HTML and text parts using placeholder variables.\n2. To review, get a template by name or list templates.\n3. To retire one, delete the template by name.\n4. Keep template names descriptive so they are easy to reference when sending.\n\n## Output\nReport the template name affected and the action taken, or the template contents for a fetch.', + '# Onboard Sending Domain\n\nVerify a new SES identity before sending from it.\n\n## Steps\n1. Create the email identity for the domain or address you want to send from.\n2. If DKIM tokens are returned, hand them to whoever manages DNS to add as CNAME records.\n3. Get the email identity periodically to check verification status and DKIM signing status until it reports success.\n4. Once verified, optionally create a configuration set to control tracking, delivery, and reputation options for emails sent from the identity.\n5. If the identity is no longer needed, delete the email identity.\n\n## Output\nReport the identity, its verification status, and DKIM status. If verification is pending, report the DNS records that still need to be added.', }, { name: 'check-sending-health', diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 4ad25a9eb03..7fdbc9faa45 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -22,6 +22,8 @@ export const STSBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'Assume Role', id: 'assume_role' }, + { label: 'Assume Role With Web Identity', id: 'assume_role_with_web_identity' }, + { label: 'Assume Role With SAML', id: 'assume_role_with_saml' }, { label: 'Get Caller Identity', id: 'get_caller_identity' }, { label: 'Get Session Token', id: 'get_session_token' }, { label: 'Get Access Key Info', id: 'get_access_key_info' }, @@ -41,7 +43,16 @@ export const STSBlock: BlockConfig = { type: 'short-input', placeholder: 'AKIA...', password: true, - required: true, + condition: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, + required: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, }, { id: 'secretAccessKey', @@ -49,30 +60,92 @@ export const STSBlock: BlockConfig = { type: 'short-input', placeholder: 'Your secret access key', password: true, - required: true, + condition: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, + required: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, }, { id: 'roleArn', title: 'Role ARN', type: 'short-input', placeholder: 'arn:aws:iam::123456789012:role/MyRole', - condition: { field: 'operation', value: 'assume_role' }, - required: { field: 'operation', value: 'assume_role' }, + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, + required: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, }, { id: 'roleSessionName', title: 'Session Name', type: 'short-input', placeholder: 'my-session', - condition: { field: 'operation', value: 'assume_role' }, - required: { field: 'operation', value: 'assume_role' }, + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity'], + }, + required: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity'], + }, + }, + { + id: 'webIdentityToken', + title: 'Web Identity Token', + type: 'long-input', + placeholder: 'OIDC/OAuth 2.0 token from the identity provider', + condition: { field: 'operation', value: 'assume_role_with_web_identity' }, + required: { field: 'operation', value: 'assume_role_with_web_identity' }, + }, + { + id: 'providerId', + title: 'Provider ID', + type: 'short-input', + placeholder: 'www.amazon.com (legacy OAuth 2.0 providers only)', + condition: { field: 'operation', value: 'assume_role_with_web_identity' }, + required: false, + mode: 'advanced', + }, + { + id: 'principalArn', + title: 'SAML Provider ARN', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:saml-provider/MyProvider', + condition: { field: 'operation', value: 'assume_role_with_saml' }, + required: { field: 'operation', value: 'assume_role_with_saml' }, + }, + { + id: 'samlAssertion', + title: 'SAML Assertion', + type: 'long-input', + placeholder: 'Base64-encoded SAML authentication response', + condition: { field: 'operation', value: 'assume_role_with_saml' }, + required: { field: 'operation', value: 'assume_role_with_saml' }, }, { id: 'durationSeconds', title: 'Duration (Seconds)', type: 'short-input', placeholder: '3600', - condition: { field: 'operation', value: ['assume_role', 'get_session_token'] }, + condition: { + field: 'operation', + value: [ + 'assume_role', + 'assume_role_with_web_identity', + 'assume_role_with_saml', + 'get_session_token', + ], + }, required: false, mode: 'advanced', }, @@ -81,6 +154,39 @@ export const STSBlock: BlockConfig = { title: 'Session Policy (JSON)', type: 'long-input', placeholder: '{"Version":"2012-10-17","Statement":[...]}', + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'policyArns', + title: 'Managed Policy ARNs', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:policy/One, arn:aws:iam::123456789012:policy/Two', + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Session Tags', + type: 'table', + columns: ['key', 'value'], + condition: { field: 'operation', value: 'assume_role' }, + required: false, + mode: 'advanced', + }, + { + id: 'transitiveTagKeys', + title: 'Transitive Tag Keys', + type: 'short-input', + placeholder: 'Project, Cost-Center', condition: { field: 'operation', value: 'assume_role' }, required: false, mode: 'advanced', @@ -124,6 +230,8 @@ export const STSBlock: BlockConfig = { tools: { access: [ 'sts_assume_role', + 'sts_assume_role_with_web_identity', + 'sts_assume_role_with_saml', 'sts_get_caller_identity', 'sts_get_session_token', 'sts_get_access_key_info', @@ -133,6 +241,10 @@ export const STSBlock: BlockConfig = { switch (params.operation) { case 'assume_role': return 'sts_assume_role' + case 'assume_role_with_web_identity': + return 'sts_assume_role_with_web_identity' + case 'assume_role_with_saml': + return 'sts_assume_role_with_saml' case 'get_caller_identity': return 'sts_get_caller_identity' case 'get_session_token': @@ -146,16 +258,12 @@ export const STSBlock: BlockConfig = { params: (params) => { const { operation, durationSeconds, ...rest } = params - const connectionConfig = { - region: rest.region, - accessKeyId: rest.accessKeyId, - secretAccessKey: rest.secretAccessKey, - } - - const result: Record = { ...connectionConfig } + const result: Record = { region: rest.region } switch (operation) { case 'assume_role': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey result.roleArn = rest.roleArn result.roleSessionName = rest.roleSessionName if (durationSeconds) { @@ -166,10 +274,53 @@ export const STSBlock: BlockConfig = { if (rest.externalId) result.externalId = rest.externalId if (rest.serialNumber) result.serialNumber = rest.serialNumber if (rest.tokenCode) result.tokenCode = rest.tokenCode + if (rest.policyArns) result.policyArns = rest.policyArns + if (rest.transitiveTagKeys) result.transitiveTagKeys = rest.transitiveTagKeys + if (rest.tags) { + const rows = rest.tags + if (typeof rows === 'string') { + result.tags = rows + } else if (Array.isArray(rows)) { + const obj: Record = {} + for (const row of rows) { + const key = row.cells?.key + const value = row.cells?.value + if (key && value !== undefined) obj[key] = String(value) + } + if (Object.keys(obj).length > 0) result.tags = JSON.stringify(obj) + } + } + break + case 'assume_role_with_web_identity': + result.roleArn = rest.roleArn + result.roleSessionName = rest.roleSessionName + result.webIdentityToken = rest.webIdentityToken + if (rest.providerId) result.providerId = rest.providerId + if (durationSeconds) { + const parsed = Number.parseInt(String(durationSeconds), 10) + if (!Number.isNaN(parsed)) result.durationSeconds = parsed + } + if (rest.policy) result.policy = rest.policy + if (rest.policyArns) result.policyArns = rest.policyArns + break + case 'assume_role_with_saml': + result.roleArn = rest.roleArn + result.principalArn = rest.principalArn + result.samlAssertion = rest.samlAssertion + if (durationSeconds) { + const parsed = Number.parseInt(String(durationSeconds), 10) + if (!Number.isNaN(parsed)) result.durationSeconds = parsed + } + if (rest.policy) result.policy = rest.policy + if (rest.policyArns) result.policyArns = rest.policyArns break case 'get_caller_identity': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey break case 'get_session_token': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey if (durationSeconds) { const parsed = Number.parseInt(String(durationSeconds), 10) if (!Number.isNaN(parsed)) result.durationSeconds = parsed @@ -178,6 +329,8 @@ export const STSBlock: BlockConfig = { if (rest.tokenCode) result.tokenCode = rest.tokenCode break case 'get_access_key_info': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey result.targetAccessKeyId = rest.targetAccessKeyId break } @@ -193,8 +346,21 @@ export const STSBlock: BlockConfig = { secretAccessKey: { type: 'string', description: 'AWS secret access key' }, roleArn: { type: 'string', description: 'ARN of the role to assume' }, roleSessionName: { type: 'string', description: 'Session name for the assumed role' }, + webIdentityToken: { + type: 'string', + description: 'OIDC/OAuth 2.0 web identity token from the identity provider', + }, + providerId: { type: 'string', description: 'Legacy OAuth 2.0 provider host' }, + principalArn: { type: 'string', description: 'ARN of the SAML provider in IAM' }, + samlAssertion: { type: 'string', description: 'Base64-encoded SAML authentication response' }, durationSeconds: { type: 'string', description: 'Session duration in seconds' }, policy: { type: 'string', description: 'JSON IAM session policy to restrict permissions' }, + policyArns: { type: 'string', description: 'Comma-separated managed policy ARNs' }, + tags: { type: 'string', description: 'Session tags (Key/Value pairs) for ABAC' }, + transitiveTagKeys: { + type: 'string', + description: 'Comma-separated tag keys that propagate through role chaining', + }, externalId: { type: 'string', description: 'External ID for cross-account access' }, serialNumber: { type: 'string', description: 'MFA device serial number' }, tokenCode: { type: 'string', description: 'MFA token code' }, @@ -203,35 +369,75 @@ export const STSBlock: BlockConfig = { outputs: { accessKeyId: { type: 'string', - description: 'Temporary access key ID (assume_role, get_session_token)', + description: + 'Temporary access key ID (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, secretAccessKey: { type: 'string', - description: 'Temporary secret access key (assume_role, get_session_token)', + description: + 'Temporary secret access key (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, sessionToken: { type: 'string', - description: 'Temporary session token (assume_role, get_session_token)', + description: + 'Temporary session token (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, expiration: { type: 'string', - description: 'Credential expiration timestamp (assume_role, get_session_token)', + description: + 'Credential expiration timestamp (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, assumedRoleArn: { type: 'string', - description: 'ARN of the assumed role (assume_role only)', + description: + 'ARN of the assumed role (assume_role, assume_role_with_web_identity, assume_role_with_saml)', }, assumedRoleId: { type: 'string', - description: 'Assumed role ID with session name (assume_role only)', + description: + 'Assumed role ID with session name (assume_role, assume_role_with_web_identity, assume_role_with_saml)', }, packedPolicySize: { type: 'number', - description: 'Percentage of allowed policy size used (assume_role only)', + description: + 'Percentage of allowed policy size used (assume_role, assume_role_with_web_identity, assume_role_with_saml)', }, sourceIdentity: { type: 'string', - description: 'Source identity set on the role session (assume_role only)', + description: + 'Source identity set on the role session (assume_role, assume_role_with_web_identity, assume_role_with_saml)', + }, + subjectFromWebIdentityToken: { + type: 'string', + description: + 'Unique user identifier from the web identity token subject claim (assume_role_with_web_identity only)', + }, + audience: { + type: 'string', + description: + 'Intended audience of the token/assertion (assume_role_with_web_identity, assume_role_with_saml)', + }, + provider: { + type: 'string', + description: + 'Issuing authority of the web identity token (assume_role_with_web_identity only)', + }, + subject: { + type: 'string', + description: 'NameID from the SAML assertion subject (assume_role_with_saml only)', + }, + subjectType: { + type: 'string', + description: 'SAML NameID format (assume_role_with_saml only)', + }, + issuer: { + type: 'string', + description: 'Issuer element of the SAML assertion (assume_role_with_saml only)', + }, + nameQualifier: { + type: 'string', + description: + 'Hash uniquely identifying the issuer, account, and SAML provider (assume_role_with_saml only)', }, account: { type: 'string', @@ -319,6 +525,24 @@ export const STSBlockMeta = { tags: ['devops', 'monitoring'], alsoIntegrations: ['identity_center', 'slack'], }, + { + icon: STSIcon, + title: 'CI/CD OIDC credential broker', + prompt: + 'Build a workflow that receives an OIDC token from a CI/CD pipeline (e.g. GitHub Actions), calls AWS STS assume role with web identity to mint short-lived deployment credentials, and writes the issuance to an audit log.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['devops', 'enterprise'], + }, + { + icon: STSIcon, + title: 'Enterprise SSO SAML role broker', + prompt: + 'Create a workflow that takes a SAML assertion from a corporate identity provider, calls AWS STS assume role with SAML to grant scoped temporary credentials, and logs the session details for compliance review.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['legal', 'enterprise'], + }, ], skills: [ { @@ -349,5 +573,19 @@ export const STSBlockMeta = { content: '# Identify Access Key Owner\n\nFind out which account owns an access key ID.\n\n## Steps\n1. Take the access key ID under investigation (for example, one found in a leak or an unexpected log).\n2. Call get access key info to resolve the owning AWS account ID.\n3. Compare the account against your known and expected accounts.\n4. If it belongs to an unexpected account, escalate for investigation.\n\n## Output\nReport the access key ID (never the secret) and its owning account ID, plus whether that account is expected.', }, + { + name: 'assume-role-with-oidc-token', + description: + 'Use AWS STS assume role with web identity to exchange an OIDC/OAuth 2.0 token (e.g. from GitHub Actions, EKS IRSA, or Google/Facebook federation) for short-lived AWS credentials without any static IAM keys.', + content: + '# Assume Role with OIDC Token\n\nExchange a federated identity token for temporary AWS credentials.\n\n## Steps\n1. Obtain the OIDC/OAuth 2.0 token issued by the identity provider (CI/CD pipeline, Kubernetes service account, or social login).\n2. Identify the role ARN whose trust policy trusts that identity provider, and choose a descriptive session name.\n3. Call assume role with web identity, passing the token and any session policy to further restrict permissions.\n4. Pass the returned temporary credentials to the downstream step that needs AWS access.\n\n## Output\nConfirm the assumed role ARN, the token subject, and credential expiration. Never print the secret access key or session token in plain logs.', + }, + { + name: 'assume-role-with-saml-assertion', + description: + 'Use AWS STS assume role with SAML to exchange a SAML 2.0 assertion from an enterprise identity provider for short-lived AWS credentials, for enterprise SSO access patterns.', + content: + '# Assume Role with SAML Assertion\n\nExchange a SAML authentication response for temporary AWS credentials.\n\n## Steps\n1. Obtain the base64-encoded SAML assertion issued by the corporate identity provider after user sign-in.\n2. Identify the role ARN and the ARN of the SAML provider entity configured in IAM.\n3. Call assume role with SAML, passing the assertion and any session policy to further restrict permissions.\n4. Pass the returned temporary credentials to the downstream step that needs AWS access.\n\n## Output\nConfirm the assumed role ARN, the assertion subject, and credential expiration. Never print the secret access key or session token in plain logs.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts new file mode 100644 index 00000000000..4fa4c964107 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const DescribeSecretSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), +}) + +const RotationRulesResponseSchema = z.object({ + automaticallyAfterDays: z.number().nullable(), + duration: z.string().nullable(), + scheduleExpression: z.string().nullable(), +}) + +const ReplicationStatusResponseSchema = z.object({ + region: z.string(), + kmsKeyId: z.string().nullable(), + status: z.string().nullable(), + statusMessage: z.string().nullable(), + lastAccessedDate: z.string().nullable(), +}) + +const DescribeSecretResponseSchema = z.object({ + name: z.string(), + arn: z.string(), + description: z.string().nullable(), + kmsKeyId: z.string().nullable(), + rotationEnabled: z.boolean(), + rotationLambdaARN: z.string().nullable(), + rotationRules: RotationRulesResponseSchema.nullable(), + lastRotatedDate: z.string().nullable(), + lastChangedDate: z.string().nullable(), + lastAccessedDate: z.string().nullable(), + deletedDate: z.string().nullable(), + nextRotationDate: z.string().nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string() })), + versionIdsToStages: z.record(z.string(), z.array(z.string())).nullable(), + owningService: z.string().nullable(), + createdDate: z.string().nullable(), + primaryRegion: z.string().nullable(), + replicationStatus: z.array(ReplicationStatusResponseSchema), +}) + +export const awsSecretsManagerDescribeSecretContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/describe-secret', + body: DescribeSecretSchema, + response: { mode: 'json', schema: DescribeSecretResponseSchema }, +}) +export type AwsSecretsManagerDescribeSecretRequest = ContractBodyInput< + typeof awsSecretsManagerDescribeSecretContract +> +export type AwsSecretsManagerDescribeSecretBody = ContractBody< + typeof awsSecretsManagerDescribeSecretContract +> +export type AwsSecretsManagerDescribeSecretResponse = ContractJsonResponse< + typeof awsSecretsManagerDescribeSecretContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts index c92e1b55258..198e751949a 100644 --- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts @@ -14,6 +14,12 @@ const ListSecretsSchema = z.object({ nextToken: z.string().nullish(), }) +const RotationRulesResponseSchema = z.object({ + automaticallyAfterDays: z.number().nullable(), + duration: z.string().nullable(), + scheduleExpression: z.string().nullable(), +}) + const ListSecretsResponseSchema = z.object({ secrets: z.array( z.object({ @@ -25,6 +31,11 @@ const ListSecretsResponseSchema = z.object({ lastAccessedDate: z.string().nullable(), rotationEnabled: z.boolean(), tags: z.array(z.object({ key: z.string(), value: z.string() })), + rotationRules: RotationRulesResponseSchema.nullable(), + lastRotatedDate: z.string().nullable(), + nextRotationDate: z.string().nullable(), + deletedDate: z.string().nullable(), + secretVersionsToStages: z.record(z.string(), z.array(z.string())).nullable(), }) ), nextToken: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts new file mode 100644 index 00000000000..68af536b83e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const RestoreSecretSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), +}) + +const RestoreSecretResponseSchema = z.object({ + message: z.string(), + name: z.string(), + arn: z.string(), +}) + +export const awsSecretsManagerRestoreSecretContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/restore-secret', + body: RestoreSecretSchema, + response: { mode: 'json', schema: RestoreSecretResponseSchema }, +}) +export type AwsSecretsManagerRestoreSecretRequest = ContractBodyInput< + typeof awsSecretsManagerRestoreSecretContract +> +export type AwsSecretsManagerRestoreSecretBody = ContractBody< + typeof awsSecretsManagerRestoreSecretContract +> +export type AwsSecretsManagerRestoreSecretResponse = ContractJsonResponse< + typeof awsSecretsManagerRestoreSecretContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts new file mode 100644 index 00000000000..8e9313245d4 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const RotateSecretSchema = z + .object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), + clientRequestToken: z + .string() + .min(32, 'Client request token must be at least 32 characters') + .max(64, 'Client request token must be at most 64 characters') + .nullish(), + rotationLambdaARN: z.string().nullish(), + automaticallyAfterDays: z + .number() + .min(1, 'Rotation interval must be at least 1 day') + .max(1000, 'Rotation interval must be at most 1000 days') + .nullish(), + duration: z + .string() + .min(2, 'Duration must be 2-3 characters, e.g. "3h"') + .max(3, 'Duration must be 2-3 characters, e.g. "3h"') + .regex(/^[0-9]+h$/, 'Duration must match the pattern h, e.g. "3h"') + .nullish(), + scheduleExpression: z + .string() + .min(1, 'Schedule expression cannot be empty') + .max(256, 'Schedule expression must be at most 256 characters') + .regex( + /^[0-9A-Za-z()#?*\-/, ]+$/, + 'Schedule expression may only contain alphanumerics and ()#?*-/, characters' + ) + .nullish(), + rotateImmediately: z.boolean().nullish(), + }) + .superRefine((data, ctx) => { + if (data.scheduleExpression && data.automaticallyAfterDays) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'automaticallyAfterDays and scheduleExpression are mutually exclusive — AWS RotationRules accepts only one', + path: ['scheduleExpression'], + }) + } + }) + +const RotateSecretResponseSchema = z.object({ + message: z.string(), + name: z.string(), + arn: z.string(), + versionId: z.string(), +}) + +export const awsSecretsManagerRotateSecretContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/rotate-secret', + body: RotateSecretSchema, + response: { mode: 'json', schema: RotateSecretResponseSchema }, +}) +export type AwsSecretsManagerRotateSecretRequest = ContractBodyInput< + typeof awsSecretsManagerRotateSecretContract +> +export type AwsSecretsManagerRotateSecretBody = ContractBody< + typeof awsSecretsManagerRotateSecretContract +> +export type AwsSecretsManagerRotateSecretResponse = ContractJsonResponse< + typeof awsSecretsManagerRotateSecretContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts new file mode 100644 index 00000000000..66d1606a5d5 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const TagResourceSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .min(1, 'At least one tag is required') + .max(50, 'A maximum of 50 tags can be attached in a single request'), +}) + +const TagResourceResponseSchema = z.object({ + message: z.string(), + name: z.string(), +}) + +export const awsSecretsManagerTagResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/tag-resource', + body: TagResourceSchema, + response: { mode: 'json', schema: TagResourceResponseSchema }, +}) +export type AwsSecretsManagerTagResourceRequest = ContractBodyInput< + typeof awsSecretsManagerTagResourceContract +> +export type AwsSecretsManagerTagResourceBody = ContractBody< + typeof awsSecretsManagerTagResourceContract +> +export type AwsSecretsManagerTagResourceResponse = ContractJsonResponse< + typeof awsSecretsManagerTagResourceContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts new file mode 100644 index 00000000000..9ec57057e0a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const UntagResourceSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), + tagKeys: z + .array( + z + .string() + .min(1, 'Tag key cannot be empty') + .max(128, 'Tag key must be at most 128 characters') + ) + .min(1, 'At least one tag key is required') + .max(50, 'A maximum of 50 tag keys can be removed in a single request'), +}) + +const UntagResourceResponseSchema = z.object({ + message: z.string(), + name: z.string(), +}) + +export const awsSecretsManagerUntagResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/untag-resource', + body: UntagResourceSchema, + response: { mode: 'json', schema: UntagResourceResponseSchema }, +}) +export type AwsSecretsManagerUntagResourceRequest = ContractBodyInput< + typeof awsSecretsManagerUntagResourceContract +> +export type AwsSecretsManagerUntagResourceBody = ContractBody< + typeof awsSecretsManagerUntagResourceContract +> +export type AwsSecretsManagerUntagResourceResponse = ContractJsonResponse< + typeof awsSecretsManagerUntagResourceContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts b/apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts new file mode 100644 index 00000000000..7900265c160 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CreateConfigurationSetSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + configurationSetName: z + .string() + .min(1, 'Configuration set name is required') + .max(64, 'Configuration set name must be 64 characters or fewer') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Configuration set name may only contain letters, numbers, hyphens, and underscores' + ), + customRedirectDomain: z.string().nullish(), + httpsPolicy: z.enum(['REQUIRE', 'REQUIRE_OPEN_ONLY', 'OPTIONAL']).nullish(), + tlsPolicy: z.enum(['REQUIRE', 'OPTIONAL']).nullish(), + sendingPoolName: z.string().nullish(), + reputationMetricsEnabled: z.boolean().nullish(), + sendingEnabled: z.boolean().nullish(), + suppressedReasons: z + .string() + .nullish() + .refine( + (v) => { + if (!v) return true + return v + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + .every((r) => r === 'BOUNCE' || r === 'COMPLAINT') + }, + { message: 'suppressedReasons must be a comma-separated list of BOUNCE, COMPLAINT' } + ), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .nullish(), + }) + .superRefine((data, ctx) => { + if (data.httpsPolicy && !data.customRedirectDomain) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'customRedirectDomain is required when httpsPolicy is set (AWS TrackingOptions requires a redirect domain)', + path: ['customRedirectDomain'], + }) + } + }) + +const CreateConfigurationSetResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesCreateConfigurationSetContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/create-configuration-set', + body: CreateConfigurationSetSchema, + response: { mode: 'json', schema: CreateConfigurationSetResponseSchema }, +}) +export type AwsSesCreateConfigurationSetRequest = ContractBodyInput< + typeof awsSesCreateConfigurationSetContract +> +export type AwsSesCreateConfigurationSetBody = ContractBody< + typeof awsSesCreateConfigurationSetContract +> +export type AwsSesCreateConfigurationSetResponse = ContractJsonResponse< + typeof awsSesCreateConfigurationSetContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts b/apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts new file mode 100644 index 00000000000..e30e508bd79 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CreateEmailIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailIdentity: z.string().min(1, 'Email identity (domain or address) is required'), + dkimSigningAttributes: z + .object({ + domainSigningSelector: z.string().optional(), + domainSigningPrivateKey: z.string().optional(), + nextSigningKeyLength: z.enum(['RSA_1024_BIT', 'RSA_2048_BIT']).optional(), + }) + .nullish(), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .nullish(), + configurationSetName: z.string().nullish(), +}) + +const DkimAttributesSchema = z.object({ + signingEnabled: z.boolean().nullable(), + status: z.string().nullable(), + tokens: z.array(z.string()), + signingAttributesOrigin: z.string().nullable(), + nextSigningKeyLength: z.string().nullable(), + currentSigningKeyLength: z.string().nullable(), + lastKeyGenerationTimestamp: z.string().nullable(), + signingHostedZone: z.string().nullable(), +}) + +const CreateEmailIdentityResponseSchema = z.object({ + identityType: z.string(), + verifiedForSendingStatus: z.boolean(), + dkimAttributes: DkimAttributesSchema.nullable(), +}) + +export const awsSesCreateEmailIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/create-email-identity', + body: CreateEmailIdentitySchema, + response: { mode: 'json', schema: CreateEmailIdentityResponseSchema }, +}) +export type AwsSesCreateEmailIdentityRequest = ContractBodyInput< + typeof awsSesCreateEmailIdentityContract +> +export type AwsSesCreateEmailIdentityBody = ContractBody +export type AwsSesCreateEmailIdentityResponse = ContractJsonResponse< + typeof awsSesCreateEmailIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts b/apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts new file mode 100644 index 00000000000..139dbf6415e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DeleteEmailIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailIdentity: z.string().min(1, 'Email identity (domain or address) is required'), +}) + +const DeleteEmailIdentityResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesDeleteEmailIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/delete-email-identity', + body: DeleteEmailIdentitySchema, + response: { mode: 'json', schema: DeleteEmailIdentityResponseSchema }, +}) +export type AwsSesDeleteEmailIdentityRequest = ContractBodyInput< + typeof awsSesDeleteEmailIdentityContract +> +export type AwsSesDeleteEmailIdentityBody = ContractBody +export type AwsSesDeleteEmailIdentityResponse = ContractJsonResponse< + typeof awsSesDeleteEmailIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts b/apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts new file mode 100644 index 00000000000..4702207987f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DeleteSuppressedDestinationSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), +}) + +const DeleteSuppressedDestinationResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesDeleteSuppressedDestinationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/delete-suppressed-destination', + body: DeleteSuppressedDestinationSchema, + response: { mode: 'json', schema: DeleteSuppressedDestinationResponseSchema }, +}) +export type AwsSesDeleteSuppressedDestinationRequest = ContractBodyInput< + typeof awsSesDeleteSuppressedDestinationContract +> +export type AwsSesDeleteSuppressedDestinationBody = ContractBody< + typeof awsSesDeleteSuppressedDestinationContract +> +export type AwsSesDeleteSuppressedDestinationResponse = ContractJsonResponse< + typeof awsSesDeleteSuppressedDestinationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts b/apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts new file mode 100644 index 00000000000..9702b359a7b --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetEmailIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailIdentity: z.string().min(1, 'Email identity (domain or address) is required'), +}) + +const DkimAttributesSchema = z.object({ + signingEnabled: z.boolean().nullable(), + status: z.string().nullable(), + tokens: z.array(z.string()), + signingAttributesOrigin: z.string().nullable(), + nextSigningKeyLength: z.string().nullable(), + currentSigningKeyLength: z.string().nullable(), + lastKeyGenerationTimestamp: z.string().nullable(), + signingHostedZone: z.string().nullable(), +}) + +const GetEmailIdentityResponseSchema = z.object({ + identityType: z.string(), + verifiedForSendingStatus: z.boolean(), + verificationStatus: z.string().nullable(), + feedbackForwardingStatus: z.boolean().nullable(), + configurationSetName: z.string().nullable(), + dkimAttributes: DkimAttributesSchema.nullable(), + mailFromAttributes: z + .object({ + mailFromDomain: z.string().nullable(), + mailFromDomainStatus: z.string().nullable(), + behaviorOnMxFailure: z.string().nullable(), + }) + .nullable(), + policies: z.record(z.string(), z.string()).nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string() })), + verificationInfo: z + .object({ + errorType: z.string().nullable(), + lastCheckedTimestamp: z.string().nullable(), + lastSuccessTimestamp: z.string().nullable(), + }) + .nullable(), +}) + +export const awsSesGetEmailIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/get-email-identity', + body: GetEmailIdentitySchema, + response: { mode: 'json', schema: GetEmailIdentityResponseSchema }, +}) +export type AwsSesGetEmailIdentityRequest = ContractBodyInput +export type AwsSesGetEmailIdentityBody = ContractBody +export type AwsSesGetEmailIdentityResponse = ContractJsonResponse< + typeof awsSesGetEmailIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts b/apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts new file mode 100644 index 00000000000..ce500ae604e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetSuppressedDestinationSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), +}) + +const GetSuppressedDestinationResponseSchema = z.object({ + emailAddress: z.string(), + reason: z.string(), + lastUpdateTime: z.string().nullable(), + messageId: z.string().nullable(), + feedbackId: z.string().nullable(), +}) + +export const awsSesGetSuppressedDestinationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/get-suppressed-destination', + body: GetSuppressedDestinationSchema, + response: { mode: 'json', schema: GetSuppressedDestinationResponseSchema }, +}) +export type AwsSesGetSuppressedDestinationRequest = ContractBodyInput< + typeof awsSesGetSuppressedDestinationContract +> +export type AwsSesGetSuppressedDestinationBody = ContractBody< + typeof awsSesGetSuppressedDestinationContract +> +export type AwsSesGetSuppressedDestinationResponse = ContractJsonResponse< + typeof awsSesGetSuppressedDestinationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts b/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts new file mode 100644 index 00000000000..db8f90d40d6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListSuppressedDestinationsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + reasons: z.string().nullish(), + startDate: z.string().nullish(), + endDate: z.string().nullish(), + pageSize: z.number().int().min(1).max(1000).nullish(), + nextToken: z.string().nullish(), +}) + +const ListSuppressedDestinationsResponseSchema = z.object({ + destinations: z.array( + z.object({ + emailAddress: z.string(), + reason: z.string(), + lastUpdateTime: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSesListSuppressedDestinationsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/list-suppressed-destinations', + body: ListSuppressedDestinationsSchema, + response: { mode: 'json', schema: ListSuppressedDestinationsResponseSchema }, +}) +export type AwsSesListSuppressedDestinationsRequest = ContractBodyInput< + typeof awsSesListSuppressedDestinationsContract +> +export type AwsSesListSuppressedDestinationsBody = ContractBody< + typeof awsSesListSuppressedDestinationsContract +> +export type AwsSesListSuppressedDestinationsResponse = ContractJsonResponse< + typeof awsSesListSuppressedDestinationsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts b/apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts new file mode 100644 index 00000000000..7f8a71f04f1 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const PutSuppressedDestinationSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), + reason: z.enum(['BOUNCE', 'COMPLAINT'], { + message: 'Reason must be BOUNCE or COMPLAINT', + }), +}) + +const PutSuppressedDestinationResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesPutSuppressedDestinationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/put-suppressed-destination', + body: PutSuppressedDestinationSchema, + response: { mode: 'json', schema: PutSuppressedDestinationResponseSchema }, +}) +export type AwsSesPutSuppressedDestinationRequest = ContractBodyInput< + typeof awsSesPutSuppressedDestinationContract +> +export type AwsSesPutSuppressedDestinationBody = ContractBody< + typeof awsSesPutSuppressedDestinationContract +> +export type AwsSesPutSuppressedDestinationResponse = ContractJsonResponse< + typeof awsSesPutSuppressedDestinationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts new file mode 100644 index 00000000000..8bfe523a8bf --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const SendCustomVerificationEmailSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), + templateName: z.string().min(1, 'Custom verification template name is required'), + configurationSetName: z.string().nullish(), +}) + +const SendCustomVerificationEmailResponseSchema = z.object({ + messageId: z.string(), +}) + +export const awsSesSendCustomVerificationEmailContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/send-custom-verification-email', + body: SendCustomVerificationEmailSchema, + response: { mode: 'json', schema: SendCustomVerificationEmailResponseSchema }, +}) +export type AwsSesSendCustomVerificationEmailRequest = ContractBodyInput< + typeof awsSesSendCustomVerificationEmailContract +> +export type AwsSesSendCustomVerificationEmailBody = ContractBody< + typeof awsSesSendCustomVerificationEmailContract +> +export type AwsSesSendCustomVerificationEmailResponse = ContractJsonResponse< + typeof awsSesSendCustomVerificationEmailContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts b/apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts new file mode 100644 index 00000000000..57534e4d668 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const UpdateTemplateSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + templateName: z.string().min(1, 'Template name is required'), + subjectPart: z.string().min(1, 'Template subject is required'), + textPart: z.string().nullish(), + htmlPart: z.string().nullish(), +}) + +const UpdateTemplateResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesUpdateTemplateContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/update-template', + body: UpdateTemplateSchema, + response: { mode: 'json', schema: UpdateTemplateResponseSchema }, +}) +export type AwsSesUpdateTemplateRequest = ContractBodyInput +export type AwsSesUpdateTemplateBody = ContractBody +export type AwsSesUpdateTemplateResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts new file mode 100644 index 00000000000..fdf7d9ff984 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const AssumeRoleWithSAMLSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + roleArn: z.string().min(20, 'Role ARN is required').max(2048), + principalArn: z.string().min(20, 'SAML provider ARN is required').max(2048), + samlAssertion: z + .string() + .min(4, 'SAML assertion is required') + .max(100000, 'SAML assertion must not exceed 100000 characters'), + policy: z.string().max(2048).nullish(), + policyArns: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((arn) => arn.trim().length > 0).length <= 10, { + message: 'A maximum of 10 policy ARNs can be provided', + }), + durationSeconds: z.number().int().min(900).max(43200).nullish(), +}) + +const AssumeRoleWithSAMLResponseSchema = z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), + sessionToken: z.string(), + expiration: z.string().nullable(), + assumedRoleArn: z.string(), + assumedRoleId: z.string(), + subject: z.string().nullable(), + subjectType: z.string().nullable(), + issuer: z.string().nullable(), + audience: z.string().nullable(), + nameQualifier: z.string().nullable(), + packedPolicySize: z.number().nullable(), + sourceIdentity: z.string().nullable(), +}) + +export const awsStsAssumeRoleWithSAMLContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sts/assume-role-with-saml', + body: AssumeRoleWithSAMLSchema, + response: { mode: 'json', schema: AssumeRoleWithSAMLResponseSchema }, +}) +export type AwsStsAssumeRoleWithSAMLRequest = ContractBodyInput< + typeof awsStsAssumeRoleWithSAMLContract +> +export type AwsStsAssumeRoleWithSAMLBody = ContractBody +export type AwsStsAssumeRoleWithSAMLResponse = ContractJsonResponse< + typeof awsStsAssumeRoleWithSAMLContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts new file mode 100644 index 00000000000..9a08299b9ce --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const AssumeRoleWithWebIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + roleArn: z.string().min(20, 'Role ARN is required').max(2048), + roleSessionName: z.string().min(2, 'Role session name is required').max(64), + webIdentityToken: z + .string() + .min(4, 'Web identity token is required') + .max(20000, 'Web identity token must not exceed 20000 characters'), + providerId: z.string().min(4).max(2048).nullish(), + policy: z.string().max(2048).nullish(), + policyArns: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((arn) => arn.trim().length > 0).length <= 10, { + message: 'A maximum of 10 policy ARNs can be provided', + }), + durationSeconds: z.number().int().min(900).max(43200).nullish(), +}) + +const AssumeRoleWithWebIdentityResponseSchema = z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), + sessionToken: z.string(), + expiration: z.string().nullable(), + assumedRoleArn: z.string(), + assumedRoleId: z.string(), + subjectFromWebIdentityToken: z.string(), + audience: z.string().nullable(), + provider: z.string().nullable(), + packedPolicySize: z.number().nullable(), + sourceIdentity: z.string().nullable(), +}) + +export const awsStsAssumeRoleWithWebIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sts/assume-role-with-web-identity', + body: AssumeRoleWithWebIdentitySchema, + response: { mode: 'json', schema: AssumeRoleWithWebIdentityResponseSchema }, +}) +export type AwsStsAssumeRoleWithWebIdentityRequest = ContractBodyInput< + typeof awsStsAssumeRoleWithWebIdentityContract +> +export type AwsStsAssumeRoleWithWebIdentityBody = ContractBody< + typeof awsStsAssumeRoleWithWebIdentityContract +> +export type AwsStsAssumeRoleWithWebIdentityResponse = ContractJsonResponse< + typeof awsStsAssumeRoleWithWebIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts index 71cd4843114..2366f94e357 100644 --- a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts @@ -20,9 +20,36 @@ const AssumeRoleSchema = z.object({ roleSessionName: z.string().min(1, 'Role session name is required'), durationSeconds: z.number().int().min(900).max(43200).nullish(), policy: z.string().max(2048).nullish(), - externalId: z.string().nullish(), + externalId: z.string().min(2).max(1224).nullish(), serialNumber: z.string().nullish(), tokenCode: z.string().nullish(), + policyArns: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((arn) => arn.trim().length > 0).length <= 10, { + message: 'A maximum of 10 policy ARNs can be provided', + }), + tags: z + .string() + .nullish() + .refine( + (v) => { + if (!v) return true + try { + const parsed = JSON.parse(v) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + } catch { + return false + } + }, + { message: 'tags must be a valid JSON object string' } + ), + transitiveTagKeys: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((key) => key.trim().length > 0).length <= 50, { + message: 'A maximum of 50 transitive tag keys can be provided', + }), }) const AssumeRoleResponseSchema = z.object({ diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 449e5c883d8..68a3676b871 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-02", + "updatedAt": "2026-07-06", "integrations": [ { "type": "onepassword", @@ -1818,7 +1818,7 @@ "slug": "aws-secrets-manager", "name": "AWS Secrets Manager", "description": "Connect to AWS Secrets Manager", - "longDescription": "Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.", + "longDescription": "Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, delete, describe, tag, untag, restore, and rotate secrets.", "bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)", "iconName": "SecretsManagerIcon", "docsUrl": "https://docs.sim.ai/integrations/secrets_manager", @@ -1842,9 +1842,29 @@ { "name": "Delete Secret", "description": "Delete a secret from AWS Secrets Manager" + }, + { + "name": "Describe Secret", + "description": "Retrieve full metadata for a secret in AWS Secrets Manager, including rotation configuration and replication status, without exposing the secret value" + }, + { + "name": "Tag Secret", + "description": "Attach tags to a secret in AWS Secrets Manager" + }, + { + "name": "Untag Secret", + "description": "Remove tags from a secret in AWS Secrets Manager" + }, + { + "name": "Restore Secret", + "description": "Cancel a scheduled deletion for a secret in AWS Secrets Manager, restoring access to it" + }, + { + "name": "Rotate Secret", + "description": "Start or reconfigure rotation for a secret in AWS Secrets Manager" } ], - "operationCount": 5, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "none", @@ -1857,7 +1877,7 @@ "slug": "aws-ses", "name": "AWS SES", "description": "Send emails and manage templates with AWS Simple Email Service", - "longDescription": "Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information.", + "longDescription": "Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates, identities, configuration sets, and the account suppression list, and retrieve account sending quota and verified identity information.", "bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)", "iconName": "SESIcon", "docsUrl": "https://docs.sim.ai/integrations/ses", @@ -1897,9 +1917,49 @@ { "name": "Delete Template", "description": "Delete an existing SES email template" + }, + { + "name": "Update Template", + "description": "Update the subject, HTML, and text content of an existing SES email template" + }, + { + "name": "Send Custom Verification Email", + "description": "Send a branded custom verification email to an address using a custom verification email template" + }, + { + "name": "Create Email Identity", + "description": "Start verification of a new SES email address or domain identity" + }, + { + "name": "Get Email Identity", + "description": "Retrieve verification status, DKIM, Mail-From, and policy details for an SES identity" + }, + { + "name": "Delete Email Identity", + "description": "Delete a verified SES email address or domain identity" + }, + { + "name": "Put Suppressed Destination", + "description": "Add an email address to the account-level SES suppression list" + }, + { + "name": "Get Suppressed Destination", + "description": "Retrieve details for a specific email address on the SES suppression list" + }, + { + "name": "List Suppressed Destinations", + "description": "List email addresses on the account-level SES suppression list" + }, + { + "name": "Delete Suppressed Destination", + "description": "Remove an email address from the account-level SES suppression list" + }, + { + "name": "Create Configuration Set", + "description": "Create an SES configuration set to control tracking, delivery, reputation, sending, and suppression behavior for emails" } ], - "operationCount": 9, + "operationCount": 19, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -1921,6 +1981,14 @@ "name": "Assume Role", "description": "Assume an IAM role and receive temporary security credentials" }, + { + "name": "Assume Role With Web Identity", + "description": "Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials" + }, + { + "name": "Assume Role With SAML", + "description": "Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials" + }, { "name": "Get Caller Identity", "description": "Get details about the IAM user or role whose credentials are used to call the API" @@ -1934,7 +2002,7 @@ "description": "Get the AWS account ID associated with an access key" } ], - "operationCount": 4, + "operationCount": 6, "triggers": [], "triggerCount": 0, "authType": "api-key", diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c293f27acf2..38f9142810a 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3137,8 +3137,13 @@ import { searchTool } from '@/tools/search' import { secretsManagerCreateSecretTool, secretsManagerDeleteSecretTool, + secretsManagerDescribeSecretTool, secretsManagerGetSecretTool, secretsManagerListSecretsTool, + secretsManagerRestoreSecretTool, + secretsManagerRotateSecretTool, + secretsManagerTagResourceTool, + secretsManagerUntagResourceTool, secretsManagerUpdateSecretTool, } from '@/tools/secrets_manager' import { @@ -3193,15 +3198,25 @@ import { servicenowUploadAttachmentTool, } from '@/tools/servicenow' import { + sesCreateConfigurationSetTool, + sesCreateEmailIdentityTool, sesCreateTemplateTool, + sesDeleteEmailIdentityTool, + sesDeleteSuppressedDestinationTool, sesDeleteTemplateTool, sesGetAccountTool, + sesGetEmailIdentityTool, + sesGetSuppressedDestinationTool, sesGetTemplateTool, sesListIdentitiesTool, + sesListSuppressedDestinationsTool, sesListTemplatesTool, + sesPutSuppressedDestinationTool, sesSendBulkEmailTool, + sesSendCustomVerificationEmailTool, sesSendEmailTool, sesSendTemplatedEmailTool, + sesUpdateTemplateTool, } from '@/tools/ses' import { sftpDeleteTool, @@ -3727,6 +3742,8 @@ import { } from '@/tools/stripe' import { stsAssumeRoleTool, + stsAssumeRoleWithSAMLTool, + stsAssumeRoleWithWebIdentityTool, stsGetAccessKeyInfoTool, stsGetCallerIdentityTool, stsGetSessionTokenTool, @@ -7035,11 +7052,16 @@ export const tools: Record = { s3_delete_bucket: s3DeleteBucketTool, s3_presigned_url: s3PresignedUrlTool, s3_delete_objects: s3DeleteObjectsTool, + secrets_manager_create_secret: secretsManagerCreateSecretTool, + secrets_manager_delete_secret: secretsManagerDeleteSecretTool, + secrets_manager_describe_secret: secretsManagerDescribeSecretTool, secrets_manager_get_secret: secretsManagerGetSecretTool, secrets_manager_list_secrets: secretsManagerListSecretsTool, - secrets_manager_create_secret: secretsManagerCreateSecretTool, + secrets_manager_restore_secret: secretsManagerRestoreSecretTool, + secrets_manager_rotate_secret: secretsManagerRotateSecretTool, + secrets_manager_tag_resource: secretsManagerTagResourceTool, + secrets_manager_untag_resource: secretsManagerUntagResourceTool, secrets_manager_update_secret: secretsManagerUpdateSecretTool, - secrets_manager_delete_secret: secretsManagerDeleteSecretTool, ses_send_email: sesSendEmailTool, ses_send_templated_email: sesSendTemplatedEmailTool, ses_send_bulk_email: sesSendBulkEmailTool, @@ -7049,6 +7071,16 @@ export const tools: Record = { ses_get_template: sesGetTemplateTool, ses_list_templates: sesListTemplatesTool, ses_delete_template: sesDeleteTemplateTool, + ses_update_template: sesUpdateTemplateTool, + ses_put_suppressed_destination: sesPutSuppressedDestinationTool, + ses_delete_suppressed_destination: sesDeleteSuppressedDestinationTool, + ses_get_suppressed_destination: sesGetSuppressedDestinationTool, + ses_list_suppressed_destinations: sesListSuppressedDestinationsTool, + ses_create_email_identity: sesCreateEmailIdentityTool, + ses_delete_email_identity: sesDeleteEmailIdentityTool, + ses_get_email_identity: sesGetEmailIdentityTool, + ses_create_configuration_set: sesCreateConfigurationSetTool, + ses_send_custom_verification_email: sesSendCustomVerificationEmailTool, telegram_message: telegramMessageTool, telegram_delete_message: telegramDeleteMessageTool, telegram_send_audio: telegramSendAudioTool, @@ -8004,6 +8036,8 @@ export const tools: Record = { sap_s4hana_update_supplier: sapS4HanaUpdateSupplierTool, sqs_send: sqsSendTool, sts_assume_role: stsAssumeRoleTool, + sts_assume_role_with_web_identity: stsAssumeRoleWithWebIdentityTool, + sts_assume_role_with_saml: stsAssumeRoleWithSAMLTool, sts_get_caller_identity: stsGetCallerIdentityTool, sts_get_session_token: stsGetSessionTokenTool, sts_get_access_key_info: stsGetAccessKeyInfoTool, diff --git a/apps/sim/tools/secrets_manager/create_secret.ts b/apps/sim/tools/secrets_manager/create_secret.ts index 331ee728588..0dd898c409f 100644 --- a/apps/sim/tools/secrets_manager/create_secret.ts +++ b/apps/sim/tools/secrets_manager/create_secret.ts @@ -11,7 +11,7 @@ export const createSecretTool: ToolConfig< id: 'secrets_manager_create_secret', name: 'Secrets Manager Create Secret', description: 'Create a new secret in AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/secrets_manager/delete_secret.ts b/apps/sim/tools/secrets_manager/delete_secret.ts index afea77a2080..c9a72f99ca2 100644 --- a/apps/sim/tools/secrets_manager/delete_secret.ts +++ b/apps/sim/tools/secrets_manager/delete_secret.ts @@ -11,7 +11,7 @@ export const deleteSecretTool: ToolConfig< id: 'secrets_manager_delete_secret', name: 'Secrets Manager Delete Secret', description: 'Delete a secret from AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/secrets_manager/describe_secret.ts b/apps/sim/tools/secrets_manager/describe_secret.ts new file mode 100644 index 00000000000..dd8018e4cda --- /dev/null +++ b/apps/sim/tools/secrets_manager/describe_secret.ts @@ -0,0 +1,152 @@ +import type { + SecretsManagerDescribeSecretParams, + SecretsManagerDescribeSecretResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const describeSecretTool: ToolConfig< + SecretsManagerDescribeSecretParams, + SecretsManagerDescribeSecretResponse +> = { + id: 'secrets_manager_describe_secret', + name: 'Secrets Manager Describe Secret', + description: + 'Retrieve full metadata for a secret in AWS Secrets Manager, including rotation configuration and replication status, without exposing the secret value', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to describe', + }, + }, + + request: { + url: '/api/tools/secrets_manager/describe-secret', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to describe secret') + } + + return { + success: true, + output: { + name: data.name ?? '', + arn: data.arn ?? '', + description: data.description ?? null, + kmsKeyId: data.kmsKeyId ?? null, + rotationEnabled: data.rotationEnabled ?? false, + rotationLambdaARN: data.rotationLambdaARN ?? null, + rotationRules: data.rotationRules ?? null, + lastRotatedDate: data.lastRotatedDate ?? null, + lastChangedDate: data.lastChangedDate ?? null, + lastAccessedDate: data.lastAccessedDate ?? null, + deletedDate: data.deletedDate ?? null, + nextRotationDate: data.nextRotationDate ?? null, + tags: data.tags ?? [], + versionIdsToStages: data.versionIdsToStages ?? null, + owningService: data.owningService ?? null, + createdDate: data.createdDate ?? null, + primaryRegion: data.primaryRegion ?? null, + replicationStatus: data.replicationStatus ?? [], + }, + error: undefined, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the secret' }, + arn: { type: 'string', description: 'ARN of the secret' }, + description: { type: 'string', description: 'Description of the secret', optional: true }, + kmsKeyId: { + type: 'string', + description: 'KMS key ID used to encrypt the secret', + optional: true, + }, + rotationEnabled: { type: 'boolean', description: 'Whether automatic rotation is enabled' }, + rotationLambdaARN: { + type: 'string', + description: 'ARN of the Lambda function used for rotation', + optional: true, + }, + rotationRules: { + type: 'json', + description: 'Rotation schedule configuration', + optional: true, + }, + lastRotatedDate: { + type: 'string', + description: 'Date the secret was last rotated', + optional: true, + }, + lastChangedDate: { + type: 'string', + description: 'Date the secret was last changed', + optional: true, + }, + lastAccessedDate: { + type: 'string', + description: 'Date the secret was last accessed', + optional: true, + }, + deletedDate: { type: 'string', description: 'Scheduled deletion date', optional: true }, + nextRotationDate: { + type: 'string', + description: 'Date the secret is next scheduled to rotate', + optional: true, + }, + tags: { type: 'array', description: 'Tags attached to the secret' }, + versionIdsToStages: { + type: 'json', + description: 'Map of version IDs to their staging labels', + optional: true, + }, + owningService: { + type: 'string', + description: 'ID of the AWS service that manages this secret, if any', + optional: true, + }, + createdDate: { type: 'string', description: 'Date the secret was created', optional: true }, + primaryRegion: { + type: 'string', + description: 'The primary region of the secret, if replicated', + optional: true, + }, + replicationStatus: { + type: 'array', + description: 'Replication status for each region the secret is replicated to', + }, + }, +} diff --git a/apps/sim/tools/secrets_manager/get_secret.ts b/apps/sim/tools/secrets_manager/get_secret.ts index 10fb58c3c9d..60d2c052eec 100644 --- a/apps/sim/tools/secrets_manager/get_secret.ts +++ b/apps/sim/tools/secrets_manager/get_secret.ts @@ -11,7 +11,7 @@ export const getSecretTool: ToolConfig< id: 'secrets_manager_get_secret', name: 'Secrets Manager Get Secret', description: 'Retrieve a secret value from AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/secrets_manager/index.ts b/apps/sim/tools/secrets_manager/index.ts index 7bc9beae221..dad93f272cb 100644 --- a/apps/sim/tools/secrets_manager/index.ts +++ b/apps/sim/tools/secrets_manager/index.ts @@ -1,7 +1,12 @@ import { createSecretTool } from './create_secret' import { deleteSecretTool } from './delete_secret' +import { describeSecretTool } from './describe_secret' import { getSecretTool } from './get_secret' import { listSecretsTool } from './list_secrets' +import { restoreSecretTool } from './restore_secret' +import { rotateSecretTool } from './rotate_secret' +import { tagResourceTool } from './tag_resource' +import { untagResourceTool } from './untag_resource' import { updateSecretTool } from './update_secret' export const secretsManagerGetSecretTool = getSecretTool @@ -9,3 +14,8 @@ export const secretsManagerListSecretsTool = listSecretsTool export const secretsManagerCreateSecretTool = createSecretTool export const secretsManagerUpdateSecretTool = updateSecretTool export const secretsManagerDeleteSecretTool = deleteSecretTool +export const secretsManagerDescribeSecretTool = describeSecretTool +export const secretsManagerTagResourceTool = tagResourceTool +export const secretsManagerUntagResourceTool = untagResourceTool +export const secretsManagerRestoreSecretTool = restoreSecretTool +export const secretsManagerRotateSecretTool = rotateSecretTool diff --git a/apps/sim/tools/secrets_manager/list_secrets.ts b/apps/sim/tools/secrets_manager/list_secrets.ts index d5e41f229eb..ab28209a870 100644 --- a/apps/sim/tools/secrets_manager/list_secrets.ts +++ b/apps/sim/tools/secrets_manager/list_secrets.ts @@ -11,7 +11,7 @@ export const listSecretsTool: ToolConfig< id: 'secrets_manager_list_secrets', name: 'Secrets Manager List Secrets', description: 'List secrets stored in AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { @@ -80,7 +80,8 @@ export const listSecretsTool: ToolConfig< outputs: { secrets: { type: 'json', - description: 'List of secrets with name, ARN, description, and dates', + description: + 'List of secrets with name, ARN, description, dates, rotation rules/window, and version-to-stage mappings', }, nextToken: { type: 'string', diff --git a/apps/sim/tools/secrets_manager/restore_secret.ts b/apps/sim/tools/secrets_manager/restore_secret.ts new file mode 100644 index 00000000000..5397b86f7f6 --- /dev/null +++ b/apps/sim/tools/secrets_manager/restore_secret.ts @@ -0,0 +1,79 @@ +import type { + SecretsManagerRestoreSecretParams, + SecretsManagerRestoreSecretResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const restoreSecretTool: ToolConfig< + SecretsManagerRestoreSecretParams, + SecretsManagerRestoreSecretResponse +> = { + id: 'secrets_manager_restore_secret', + name: 'Secrets Manager Restore Secret', + description: + 'Cancel a scheduled deletion for a secret in AWS Secrets Manager, restoring access to it', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to restore', + }, + }, + + request: { + url: '/api/tools/secrets_manager/restore-secret', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to restore secret') + } + + return { + success: true, + output: { + message: data.message || 'Secret restored successfully', + name: data.name ?? '', + arn: data.arn ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name of the restored secret' }, + arn: { type: 'string', description: 'ARN of the restored secret' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/rotate_secret.ts b/apps/sim/tools/secrets_manager/rotate_secret.ts new file mode 100644 index 00000000000..d5167d25918 --- /dev/null +++ b/apps/sim/tools/secrets_manager/rotate_secret.ts @@ -0,0 +1,124 @@ +import type { + SecretsManagerRotateSecretParams, + SecretsManagerRotateSecretResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const rotateSecretTool: ToolConfig< + SecretsManagerRotateSecretParams, + SecretsManagerRotateSecretResponse +> = { + id: 'secrets_manager_rotate_secret', + name: 'Secrets Manager Rotate Secret', + description: 'Start or reconfigure rotation for a secret in AWS Secrets Manager', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to rotate', + }, + clientRequestToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Idempotency token for the new secret version (32-64 characters)', + }, + rotationLambdaARN: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ARN of the Lambda function that performs rotation (omit for managed rotation)', + }, + automaticallyAfterDays: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Number of days between rotations (1-1000). Mutually exclusive with schedule expression', + }, + duration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Length of the rotation window in hours, e.g. "3h"', + }, + scheduleExpression: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'A cron() or rate() expression defining the rotation schedule', + }, + rotateImmediately: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether to rotate immediately (default true) or wait for the next scheduled window', + }, + }, + + request: { + url: '/api/tools/secrets_manager/rotate-secret', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + clientRequestToken: params.clientRequestToken, + rotationLambdaARN: params.rotationLambdaARN, + automaticallyAfterDays: params.automaticallyAfterDays, + duration: params.duration, + scheduleExpression: params.scheduleExpression, + rotateImmediately: params.rotateImmediately, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to rotate secret') + } + + return { + success: true, + output: { + message: data.message || 'Rotation started successfully', + name: data.name ?? '', + arn: data.arn ?? '', + versionId: data.versionId ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name of the secret' }, + arn: { type: 'string', description: 'ARN of the secret' }, + versionId: { type: 'string', description: 'ID of the new secret version created by rotation' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/tag_resource.ts b/apps/sim/tools/secrets_manager/tag_resource.ts new file mode 100644 index 00000000000..d5e17cf30c4 --- /dev/null +++ b/apps/sim/tools/secrets_manager/tag_resource.ts @@ -0,0 +1,83 @@ +import type { + SecretsManagerTagResourceParams, + SecretsManagerTagResourceResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const tagResourceTool: ToolConfig< + SecretsManagerTagResourceParams, + SecretsManagerTagResourceResponse +> = { + id: 'secrets_manager_tag_resource', + name: 'Secrets Manager Tag Resource', + description: 'Attach tags to a secret in AWS Secrets Manager', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to tag', + }, + tags: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Tags to attach, as an array of {key, value} pairs (max 50)', + }, + }, + + request: { + url: '/api/tools/secrets_manager/tag-resource', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + tags: params.tags, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to tag secret') + } + + return { + success: true, + output: { + message: data.message || 'Secret tagged successfully', + name: data.name ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name or ARN of the tagged secret' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/types.ts b/apps/sim/tools/secrets_manager/types.ts index 48c40e6d0f2..9b3475dfa7a 100644 --- a/apps/sim/tools/secrets_manager/types.ts +++ b/apps/sim/tools/secrets_manager/types.ts @@ -52,6 +52,12 @@ export interface SecretsManagerGetSecretResponse extends ToolResponse { error?: string } +export interface SecretsManagerRotationRules { + automaticallyAfterDays: number | null + duration: string | null + scheduleExpression: string | null +} + export interface SecretsManagerListSecretsResponse extends ToolResponse { output: { secrets: Array<{ @@ -63,6 +69,11 @@ export interface SecretsManagerListSecretsResponse extends ToolResponse { lastAccessedDate: string | null rotationEnabled: boolean tags: Array<{ key: string; value: string }> + rotationRules: SecretsManagerRotationRules | null + lastRotatedDate: string | null + nextRotationDate: string | null + deletedDate: string | null + secretVersionsToStages: Record | null }> nextToken: string | null count: number @@ -99,3 +110,98 @@ export interface SecretsManagerDeleteSecretResponse extends ToolResponse { } error?: string } + +export interface SecretsManagerDescribeSecretParams extends SecretsManagerConnectionConfig { + secretId: string +} + +export interface SecretsManagerReplicationStatus { + region: string + kmsKeyId: string | null + status: string | null + statusMessage: string | null + lastAccessedDate: string | null +} + +export interface SecretsManagerDescribeSecretResponse extends ToolResponse { + output: { + name: string + arn: string + description: string | null + kmsKeyId: string | null + rotationEnabled: boolean + rotationLambdaARN: string | null + rotationRules: SecretsManagerRotationRules | null + lastRotatedDate: string | null + lastChangedDate: string | null + lastAccessedDate: string | null + deletedDate: string | null + nextRotationDate: string | null + tags: Array<{ key: string; value: string }> + versionIdsToStages: Record | null + owningService: string | null + createdDate: string | null + primaryRegion: string | null + replicationStatus: SecretsManagerReplicationStatus[] + } + error?: string +} + +export interface SecretsManagerTagResourceParams extends SecretsManagerConnectionConfig { + secretId: string + tags: Array<{ key: string; value: string }> +} + +export interface SecretsManagerTagResourceResponse extends ToolResponse { + output: { + message: string + name: string + } + error?: string +} + +export interface SecretsManagerUntagResourceParams extends SecretsManagerConnectionConfig { + secretId: string + tagKeys: string[] +} + +export interface SecretsManagerUntagResourceResponse extends ToolResponse { + output: { + message: string + name: string + } + error?: string +} + +export interface SecretsManagerRestoreSecretParams extends SecretsManagerConnectionConfig { + secretId: string +} + +export interface SecretsManagerRestoreSecretResponse extends ToolResponse { + output: { + message: string + name: string + arn: string + } + error?: string +} + +export interface SecretsManagerRotateSecretParams extends SecretsManagerConnectionConfig { + secretId: string + clientRequestToken?: string | null + rotationLambdaARN?: string | null + automaticallyAfterDays?: number | null + duration?: string | null + scheduleExpression?: string | null + rotateImmediately?: boolean | null +} + +export interface SecretsManagerRotateSecretResponse extends ToolResponse { + output: { + message: string + name: string + arn: string + versionId: string + } + error?: string +} diff --git a/apps/sim/tools/secrets_manager/untag_resource.ts b/apps/sim/tools/secrets_manager/untag_resource.ts new file mode 100644 index 00000000000..22e77205233 --- /dev/null +++ b/apps/sim/tools/secrets_manager/untag_resource.ts @@ -0,0 +1,83 @@ +import type { + SecretsManagerUntagResourceParams, + SecretsManagerUntagResourceResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const untagResourceTool: ToolConfig< + SecretsManagerUntagResourceParams, + SecretsManagerUntagResourceResponse +> = { + id: 'secrets_manager_untag_resource', + name: 'Secrets Manager Untag Resource', + description: 'Remove tags from a secret in AWS Secrets Manager', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to untag', + }, + tagKeys: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Tag keys to remove, as an array of strings (max 50)', + }, + }, + + request: { + url: '/api/tools/secrets_manager/untag-resource', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + tagKeys: params.tagKeys, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to untag secret') + } + + return { + success: true, + output: { + message: data.message || 'Secret untagged successfully', + name: data.name ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name or ARN of the untagged secret' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/update_secret.ts b/apps/sim/tools/secrets_manager/update_secret.ts index a5a59dce681..02ec27a70df 100644 --- a/apps/sim/tools/secrets_manager/update_secret.ts +++ b/apps/sim/tools/secrets_manager/update_secret.ts @@ -11,7 +11,7 @@ export const updateSecretTool: ToolConfig< id: 'secrets_manager_update_secret', name: 'Secrets Manager Update Secret', description: 'Update the value of an existing secret in AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/ses/create_configuration_set.ts b/apps/sim/tools/ses/create_configuration_set.ts new file mode 100644 index 00000000000..c5e42aa56f3 --- /dev/null +++ b/apps/sim/tools/ses/create_configuration_set.ts @@ -0,0 +1,131 @@ +import type { + SESCreateConfigurationSetParams, + SESCreateConfigurationSetResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const createConfigurationSetTool: ToolConfig< + SESCreateConfigurationSetParams, + SESCreateConfigurationSetResponse +> = { + id: 'ses_create_configuration_set', + name: 'SES Create Configuration Set', + description: + 'Create an SES configuration set to control tracking, delivery, reputation, sending, and suppression behavior for emails', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + configurationSetName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the configuration set (letters, numbers, hyphens, underscores)', + }, + customRedirectDomain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom domain to use for open/click tracking links', + }, + httpsPolicy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'HTTPS policy for tracking links: REQUIRE, REQUIRE_OPEN_ONLY, or OPTIONAL', + }, + tlsPolicy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Whether delivery requires TLS: REQUIRE or OPTIONAL', + }, + sendingPoolName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Dedicated IP pool to associate with the configuration set', + }, + reputationMetricsEnabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to collect reputation metrics for emails using this configuration set', + }, + sendingEnabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether sending is enabled for this configuration set', + }, + suppressedReasons: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated reasons that trigger suppression: BOUNCE, COMPLAINT', + }, + tags: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of tags to associate with the configuration set: [{"key":"","value":""}]', + }, + }, + + request: { + url: '/api/tools/ses/create-configuration-set', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + configurationSetName: params.configurationSetName, + customRedirectDomain: params.customRedirectDomain, + httpsPolicy: params.httpsPolicy, + tlsPolicy: params.tlsPolicy, + sendingPoolName: params.sendingPoolName, + reputationMetricsEnabled: params.reputationMetricsEnabled, + sendingEnabled: params.sendingEnabled, + suppressedReasons: params.suppressedReasons, + tags: params.tags, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create configuration set') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/create_email_identity.ts b/apps/sim/tools/ses/create_email_identity.ts new file mode 100644 index 00000000000..df44525bc11 --- /dev/null +++ b/apps/sim/tools/ses/create_email_identity.ts @@ -0,0 +1,106 @@ +import type { + SESCreateEmailIdentityParams, + SESCreateEmailIdentityResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const createEmailIdentityTool: ToolConfig< + SESCreateEmailIdentityParams, + SESCreateEmailIdentityResponse +> = { + id: 'ses_create_email_identity', + name: 'SES Create Email Identity', + description: 'Start verification of a new SES email address or domain identity', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailIdentity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address or domain to verify', + }, + dkimSigningAttributes: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Bring-your-own-DKIM signing attributes as JSON (domainSigningSelector, domainSigningPrivateKey, nextSigningKeyLength). Domain identities only.', + }, + tags: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of tags to associate with the identity: [{"key":"","value":""}]', + }, + configurationSetName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Default configuration set to use when sending from this identity', + }, + }, + + request: { + url: '/api/tools/ses/create-email-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailIdentity: params.emailIdentity, + dkimSigningAttributes: params.dkimSigningAttributes, + tags: params.tags, + configurationSetName: params.configurationSetName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create email identity') + } + + return { + success: true, + output: { + identityType: data.identityType ?? '', + verifiedForSendingStatus: data.verifiedForSendingStatus ?? false, + dkimAttributes: data.dkimAttributes ?? null, + }, + } + }, + + outputs: { + identityType: { type: 'string', description: 'The identity type: EMAIL_ADDRESS or DOMAIN' }, + verifiedForSendingStatus: { + type: 'boolean', + description: 'Whether the identity is verified and can send email', + }, + dkimAttributes: { + type: 'json', + description: 'DKIM signing status and CNAME tokens for the identity', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/ses/delete_email_identity.ts b/apps/sim/tools/ses/delete_email_identity.ts new file mode 100644 index 00000000000..270f4cb9bb6 --- /dev/null +++ b/apps/sim/tools/ses/delete_email_identity.ts @@ -0,0 +1,73 @@ +import type { + SESDeleteEmailIdentityParams, + SESDeleteEmailIdentityResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteEmailIdentityTool: ToolConfig< + SESDeleteEmailIdentityParams, + SESDeleteEmailIdentityResponse +> = { + id: 'ses_delete_email_identity', + name: 'SES Delete Email Identity', + description: 'Delete a verified SES email address or domain identity', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailIdentity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address or domain identity to delete', + }, + }, + + request: { + url: '/api/tools/ses/delete-email-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailIdentity: params.emailIdentity, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete email identity') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/delete_suppressed_destination.ts b/apps/sim/tools/ses/delete_suppressed_destination.ts new file mode 100644 index 00000000000..1f7cf217531 --- /dev/null +++ b/apps/sim/tools/ses/delete_suppressed_destination.ts @@ -0,0 +1,73 @@ +import type { + SESDeleteSuppressedDestinationParams, + SESDeleteSuppressedDestinationResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteSuppressedDestinationTool: ToolConfig< + SESDeleteSuppressedDestinationParams, + SESDeleteSuppressedDestinationResponse +> = { + id: 'ses_delete_suppressed_destination', + name: 'SES Delete Suppressed Destination', + description: 'Remove an email address from the account-level SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address to remove from the suppression list', + }, + }, + + request: { + url: '/api/tools/ses/delete-suppressed-destination', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to remove suppressed destination') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/get_email_identity.ts b/apps/sim/tools/ses/get_email_identity.ts new file mode 100644 index 00000000000..04d37751044 --- /dev/null +++ b/apps/sim/tools/ses/get_email_identity.ts @@ -0,0 +1,120 @@ +import type { SESGetEmailIdentityParams, SESGetEmailIdentityResponse } from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const getEmailIdentityTool: ToolConfig< + SESGetEmailIdentityParams, + SESGetEmailIdentityResponse +> = { + id: 'ses_get_email_identity', + name: 'SES Get Email Identity', + description: + 'Retrieve verification status, DKIM, Mail-From, and policy details for an SES identity', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailIdentity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address or domain identity to look up', + }, + }, + + request: { + url: '/api/tools/ses/get-email-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailIdentity: params.emailIdentity, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get email identity') + } + + return { + success: true, + output: { + identityType: data.identityType ?? '', + verifiedForSendingStatus: data.verifiedForSendingStatus ?? false, + verificationStatus: data.verificationStatus ?? null, + feedbackForwardingStatus: data.feedbackForwardingStatus ?? null, + configurationSetName: data.configurationSetName ?? null, + dkimAttributes: data.dkimAttributes ?? null, + mailFromAttributes: data.mailFromAttributes ?? null, + policies: data.policies ?? null, + tags: data.tags ?? [], + verificationInfo: data.verificationInfo ?? null, + }, + } + }, + + outputs: { + identityType: { type: 'string', description: 'The identity type: EMAIL_ADDRESS or DOMAIN' }, + verifiedForSendingStatus: { + type: 'boolean', + description: 'Whether the identity is verified and can send email', + }, + verificationStatus: { + type: 'string', + description: 'Verification status: PENDING, SUCCESS, FAILED, TEMPORARY_FAILURE, NOT_STARTED', + optional: true, + }, + feedbackForwardingStatus: { + type: 'boolean', + description: 'Whether bounce/complaint notifications are forwarded by email', + optional: true, + }, + configurationSetName: { + type: 'string', + description: 'Default configuration set for this identity', + optional: true, + }, + dkimAttributes: { + type: 'json', + description: 'DKIM signing status and CNAME tokens for the identity', + optional: true, + }, + mailFromAttributes: { + type: 'json', + description: 'Custom MAIL FROM domain configuration for the identity', + optional: true, + }, + policies: { + type: 'json', + description: 'Sending authorization policies attached to the identity', + optional: true, + }, + tags: { type: 'array', description: 'Tags associated with the identity' }, + verificationInfo: { + type: 'json', + description: 'Additional verification diagnostics (error type, last checked/success time)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/ses/get_suppressed_destination.ts b/apps/sim/tools/ses/get_suppressed_destination.ts new file mode 100644 index 00000000000..3cd9e1f67f8 --- /dev/null +++ b/apps/sim/tools/ses/get_suppressed_destination.ts @@ -0,0 +1,93 @@ +import type { + SESGetSuppressedDestinationParams, + SESGetSuppressedDestinationResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const getSuppressedDestinationTool: ToolConfig< + SESGetSuppressedDestinationParams, + SESGetSuppressedDestinationResponse +> = { + id: 'ses_get_suppressed_destination', + name: 'SES Get Suppressed Destination', + description: 'Retrieve details for a specific email address on the SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The suppressed email address to look up', + }, + }, + + request: { + url: '/api/tools/ses/get-suppressed-destination', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get suppressed destination') + } + + return { + success: true, + output: { + emailAddress: data.emailAddress ?? '', + reason: data.reason ?? '', + lastUpdateTime: data.lastUpdateTime ?? null, + messageId: data.messageId ?? null, + feedbackId: data.feedbackId ?? null, + }, + } + }, + + outputs: { + emailAddress: { type: 'string', description: 'The suppressed email address' }, + reason: { type: 'string', description: 'The reason the address is suppressed' }, + lastUpdateTime: { + type: 'string', + description: 'When the address was added to the suppression list', + optional: true, + }, + messageId: { + type: 'string', + description: 'The message ID associated with the bounce or complaint event', + optional: true, + }, + feedbackId: { + type: 'string', + description: 'The feedback ID associated with the bounce or complaint event', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/ses/index.ts b/apps/sim/tools/ses/index.ts index be2aa6ccd98..b30522ee690 100644 --- a/apps/sim/tools/ses/index.ts +++ b/apps/sim/tools/ses/index.ts @@ -1,12 +1,22 @@ +import { createConfigurationSetTool } from './create_configuration_set' +import { createEmailIdentityTool } from './create_email_identity' import { createTemplateTool } from './create_template' +import { deleteEmailIdentityTool } from './delete_email_identity' +import { deleteSuppressedDestinationTool } from './delete_suppressed_destination' import { deleteTemplateTool } from './delete_template' import { getAccountTool } from './get_account' +import { getEmailIdentityTool } from './get_email_identity' +import { getSuppressedDestinationTool } from './get_suppressed_destination' import { getTemplateTool } from './get_template' import { listIdentitiesTool } from './list_identities' +import { listSuppressedDestinationsTool } from './list_suppressed_destinations' import { listTemplatesTool } from './list_templates' +import { putSuppressedDestinationTool } from './put_suppressed_destination' import { sendBulkEmailTool } from './send_bulk_email' +import { sendCustomVerificationEmailTool } from './send_custom_verification_email' import { sendEmailTool } from './send_email' import { sendTemplatedEmailTool } from './send_templated_email' +import { updateTemplateTool } from './update_template' export const sesSendEmailTool = sendEmailTool export const sesSendTemplatedEmailTool = sendTemplatedEmailTool @@ -17,5 +27,15 @@ export const sesCreateTemplateTool = createTemplateTool export const sesGetTemplateTool = getTemplateTool export const sesListTemplatesTool = listTemplatesTool export const sesDeleteTemplateTool = deleteTemplateTool +export const sesUpdateTemplateTool = updateTemplateTool +export const sesPutSuppressedDestinationTool = putSuppressedDestinationTool +export const sesDeleteSuppressedDestinationTool = deleteSuppressedDestinationTool +export const sesGetSuppressedDestinationTool = getSuppressedDestinationTool +export const sesListSuppressedDestinationsTool = listSuppressedDestinationsTool +export const sesCreateEmailIdentityTool = createEmailIdentityTool +export const sesDeleteEmailIdentityTool = deleteEmailIdentityTool +export const sesGetEmailIdentityTool = getEmailIdentityTool +export const sesCreateConfigurationSetTool = createConfigurationSetTool +export const sesSendCustomVerificationEmailTool = sendCustomVerificationEmailTool export * from './types' diff --git a/apps/sim/tools/ses/list_suppressed_destinations.ts b/apps/sim/tools/ses/list_suppressed_destinations.ts new file mode 100644 index 00000000000..58ab851f10d --- /dev/null +++ b/apps/sim/tools/ses/list_suppressed_destinations.ts @@ -0,0 +1,112 @@ +import type { + SESListSuppressedDestinationsParams, + SESListSuppressedDestinationsResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const listSuppressedDestinationsTool: ToolConfig< + SESListSuppressedDestinationsParams, + SESListSuppressedDestinationsResponse +> = { + id: 'ses_list_suppressed_destinations', + name: 'SES List Suppressed Destinations', + description: 'List email addresses on the account-level SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + reasons: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated suppression reasons to filter by: BOUNCE, COMPLAINT', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include addresses suppressed after this ISO 8601 date', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include addresses suppressed before this ISO 8601 date', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous list response', + }, + }, + + request: { + url: '/api/tools/ses/list-suppressed-destinations', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + reasons: params.reasons, + startDate: params.startDate, + endDate: params.endDate, + pageSize: params.pageSize, + nextToken: params.nextToken, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to list suppressed destinations') + } + + return { + success: true, + output: { + destinations: data.destinations ?? [], + nextToken: data.nextToken ?? null, + count: data.count ?? 0, + }, + } + }, + + outputs: { + destinations: { + type: 'array', + description: 'List of suppressed destinations with email address, reason, and last update', + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of results', + optional: true, + }, + count: { type: 'number', description: 'Number of suppressed destinations returned' }, + }, +} diff --git a/apps/sim/tools/ses/put_suppressed_destination.ts b/apps/sim/tools/ses/put_suppressed_destination.ts new file mode 100644 index 00000000000..25701e32608 --- /dev/null +++ b/apps/sim/tools/ses/put_suppressed_destination.ts @@ -0,0 +1,80 @@ +import type { + SESPutSuppressedDestinationParams, + SESPutSuppressedDestinationResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const putSuppressedDestinationTool: ToolConfig< + SESPutSuppressedDestinationParams, + SESPutSuppressedDestinationResponse +> = { + id: 'ses_put_suppressed_destination', + name: 'SES Put Suppressed Destination', + description: 'Add an email address to the account-level SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address to add to the suppression list', + }, + reason: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The reason the address is suppressed: BOUNCE or COMPLAINT', + }, + }, + + request: { + url: '/api/tools/ses/put-suppressed-destination', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + reason: params.reason, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to add suppressed destination') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/send_custom_verification_email.ts b/apps/sim/tools/ses/send_custom_verification_email.ts new file mode 100644 index 00000000000..66d4f9f59fd --- /dev/null +++ b/apps/sim/tools/ses/send_custom_verification_email.ts @@ -0,0 +1,88 @@ +import type { + SESSendCustomVerificationEmailParams, + SESSendCustomVerificationEmailResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const sendCustomVerificationEmailTool: ToolConfig< + SESSendCustomVerificationEmailParams, + SESSendCustomVerificationEmailResponse +> = { + id: 'ses_send_custom_verification_email', + name: 'SES Send Custom Verification Email', + description: + 'Send a branded custom verification email to an address using a custom verification email template', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address to verify', + }, + templateName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the custom verification email template to use', + }, + configurationSetName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Configuration set to use when sending the verification email', + }, + }, + + request: { + url: '/api/tools/ses/send-custom-verification-email', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + templateName: params.templateName, + configurationSetName: params.configurationSetName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to send custom verification email') + } + + return { + success: true, + output: { + messageId: data.messageId ?? '', + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'SES message ID for the sent verification email' }, + }, +} diff --git a/apps/sim/tools/ses/types.ts b/apps/sim/tools/ses/types.ts index 1f5f29046fc..671dda155d9 100644 --- a/apps/sim/tools/ses/types.ts +++ b/apps/sim/tools/ses/types.ts @@ -141,6 +141,178 @@ export interface SESDeleteTemplateResponse extends ToolResponse { } } +export interface SESPutSuppressedDestinationParams extends SESConnectionConfig { + emailAddress: string + reason: 'BOUNCE' | 'COMPLAINT' +} + +export interface SESPutSuppressedDestinationResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESDeleteSuppressedDestinationParams extends SESConnectionConfig { + emailAddress: string +} + +export interface SESDeleteSuppressedDestinationResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESGetSuppressedDestinationParams extends SESConnectionConfig { + emailAddress: string +} + +export interface SESGetSuppressedDestinationResponse extends ToolResponse { + output: { + emailAddress: string + reason: string + lastUpdateTime: string | null + messageId: string | null + feedbackId: string | null + } +} + +export interface SESListSuppressedDestinationsParams extends SESConnectionConfig { + reasons?: string | null + startDate?: string | null + endDate?: string | null + pageSize?: number | null + nextToken?: string | null +} + +export interface SESListSuppressedDestinationsResponse extends ToolResponse { + output: { + destinations: Array<{ + emailAddress: string + reason: string + lastUpdateTime: string | null + }> + nextToken: string | null + count: number + } +} + +export interface SESCreateEmailIdentityParams extends SESConnectionConfig { + emailIdentity: string + dkimSigningAttributes?: { + domainSigningSelector?: string + domainSigningPrivateKey?: string + nextSigningKeyLength?: 'RSA_1024_BIT' | 'RSA_2048_BIT' + } | null + tags?: Array<{ key: string; value: string }> | null + configurationSetName?: string | null +} + +export interface SESCreateEmailIdentityResponse extends ToolResponse { + output: { + identityType: string + verifiedForSendingStatus: boolean + dkimAttributes: { + signingEnabled: boolean | null + status: string | null + tokens: string[] + signingAttributesOrigin: string | null + nextSigningKeyLength: string | null + currentSigningKeyLength: string | null + lastKeyGenerationTimestamp: string | null + signingHostedZone: string | null + } | null + } +} + +export interface SESDeleteEmailIdentityParams extends SESConnectionConfig { + emailIdentity: string +} + +export interface SESDeleteEmailIdentityResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESGetEmailIdentityParams extends SESConnectionConfig { + emailIdentity: string +} + +export interface SESGetEmailIdentityResponse extends ToolResponse { + output: { + identityType: string + verifiedForSendingStatus: boolean + verificationStatus: string | null + feedbackForwardingStatus: boolean | null + configurationSetName: string | null + dkimAttributes: { + signingEnabled: boolean | null + status: string | null + tokens: string[] + signingAttributesOrigin: string | null + nextSigningKeyLength: string | null + currentSigningKeyLength: string | null + lastKeyGenerationTimestamp: string | null + signingHostedZone: string | null + } | null + mailFromAttributes: { + mailFromDomain: string | null + mailFromDomainStatus: string | null + behaviorOnMxFailure: string | null + } | null + policies: Record | null + tags: Array<{ key: string; value: string }> + verificationInfo: { + errorType: string | null + lastCheckedTimestamp: string | null + lastSuccessTimestamp: string | null + } | null + } +} + +export interface SESUpdateTemplateParams extends SESConnectionConfig { + templateName: string + subjectPart: string + textPart?: string | null + htmlPart?: string | null +} + +export interface SESUpdateTemplateResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESCreateConfigurationSetParams extends SESConnectionConfig { + configurationSetName: string + customRedirectDomain?: string | null + httpsPolicy?: 'REQUIRE' | 'REQUIRE_OPEN_ONLY' | 'OPTIONAL' | null + tlsPolicy?: 'REQUIRE' | 'OPTIONAL' | null + sendingPoolName?: string | null + reputationMetricsEnabled?: boolean | null + sendingEnabled?: boolean | null + suppressedReasons?: string | null + tags?: Array<{ key: string; value: string }> | null +} + +export interface SESCreateConfigurationSetResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESSendCustomVerificationEmailParams extends SESConnectionConfig { + emailAddress: string + templateName: string + configurationSetName?: string | null +} + +export interface SESSendCustomVerificationEmailResponse extends ToolResponse { + output: { + messageId: string + } +} + interface SESBaseResponse extends ToolResponse { output: { message: string } } diff --git a/apps/sim/tools/ses/update_template.ts b/apps/sim/tools/ses/update_template.ts new file mode 100644 index 00000000000..288b76af8f7 --- /dev/null +++ b/apps/sim/tools/ses/update_template.ts @@ -0,0 +1,88 @@ +import type { SESUpdateTemplateParams, SESUpdateTemplateResponse } from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const updateTemplateTool: ToolConfig = { + id: 'ses_update_template', + name: 'SES Update Template', + description: 'Update the subject, HTML, and text content of an existing SES email template', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + templateName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the template to update', + }, + subjectPart: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The subject line of the template', + }, + htmlPart: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The HTML body of the template', + }, + textPart: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The plain text body of the template', + }, + }, + + request: { + url: '/api/tools/ses/update-template', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + templateName: params.templateName, + subjectPart: params.subjectPart, + htmlPart: params.htmlPart, + textPart: params.textPart, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to update template') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/sts/assume_role.ts b/apps/sim/tools/sts/assume_role.ts index 2e20e89af31..7422dfd7675 100644 --- a/apps/sim/tools/sts/assume_role.ts +++ b/apps/sim/tools/sts/assume_role.ts @@ -68,6 +68,26 @@ export const assumeRoleTool: ToolConfig = { + id: 'sts_assume_role_with_saml', + name: 'STS Assume Role With SAML', + description: + 'Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + roleArn: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ARN of the IAM role to assume', + }, + principalArn: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ARN of the SAML provider in IAM that describes the identity provider', + }, + samlAssertion: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Base64-encoded SAML authentication response from the identity provider', + }, + policyArns: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated ARNs of up to 10 IAM managed policies to use as session policies', + }, + policy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON IAM policy to further restrict session permissions (max 2048 chars)', + }, + durationSeconds: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Duration of the session in seconds (900-43200, default 3600)', + }, + }, + + request: { + url: '/api/tools/sts/assume-role-with-saml', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + roleArn: params.roleArn, + principalArn: params.principalArn, + samlAssertion: params.samlAssertion, + policyArns: params.policyArns, + policy: params.policy, + durationSeconds: params.durationSeconds, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to assume role with SAML') + } + + return { + success: true, + output: { + accessKeyId: data.accessKeyId ?? '', + secretAccessKey: data.secretAccessKey ?? '', + sessionToken: data.sessionToken ?? '', + expiration: data.expiration ?? null, + assumedRoleArn: data.assumedRoleArn ?? '', + assumedRoleId: data.assumedRoleId ?? '', + subject: data.subject ?? null, + subjectType: data.subjectType ?? null, + issuer: data.issuer ?? null, + audience: data.audience ?? null, + nameQualifier: data.nameQualifier ?? null, + packedPolicySize: data.packedPolicySize ?? null, + sourceIdentity: data.sourceIdentity ?? null, + }, + } + }, + + outputs: { + accessKeyId: { type: 'string', description: 'Temporary access key ID' }, + secretAccessKey: { type: 'string', description: 'Temporary secret access key' }, + sessionToken: { type: 'string', description: 'Temporary session token' }, + expiration: { type: 'string', description: 'Credential expiration timestamp', optional: true }, + assumedRoleArn: { type: 'string', description: 'ARN of the assumed role' }, + assumedRoleId: { type: 'string', description: 'Assumed role ID with session name' }, + subject: { + type: 'string', + description: 'Value of the NameID element in the Subject of the SAML assertion', + optional: true, + }, + subjectType: { + type: 'string', + description: 'Format of the name ID (e.g. transient, persistent)', + optional: true, + }, + issuer: { + type: 'string', + description: 'Value of the Issuer element of the SAML assertion', + optional: true, + }, + audience: { + type: 'string', + description: "Value of the SAML assertion's SubjectConfirmationData Recipient attribute", + optional: true, + }, + nameQualifier: { + type: 'string', + description: 'Hash uniquely identifying the issuer, account, and SAML provider', + optional: true, + }, + packedPolicySize: { + type: 'number', + description: 'Percentage of allowed policy size used', + optional: true, + }, + sourceIdentity: { + type: 'string', + description: 'Source identity set on the role session, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/sts/assume_role_with_web_identity.ts b/apps/sim/tools/sts/assume_role_with_web_identity.ts new file mode 100644 index 00000000000..26afa9661ea --- /dev/null +++ b/apps/sim/tools/sts/assume_role_with_web_identity.ts @@ -0,0 +1,144 @@ +import type { + STSAssumeRoleWithWebIdentityParams, + STSAssumeRoleWithWebIdentityResponse, +} from '@/tools/sts/types' +import type { ToolConfig } from '@/tools/types' + +export const assumeRoleWithWebIdentityTool: ToolConfig< + STSAssumeRoleWithWebIdentityParams, + STSAssumeRoleWithWebIdentityResponse +> = { + id: 'sts_assume_role_with_web_identity', + name: 'STS Assume Role With Web Identity', + description: + 'Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + roleArn: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ARN of the IAM role to assume', + }, + roleSessionName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Identifier for the assumed role session', + }, + webIdentityToken: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'OAuth 2.0 access token or OpenID Connect ID token from the identity provider (up to 20000 chars)', + }, + providerId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Fully qualified host of a legacy OAuth 2.0 provider (e.g. www.amazon.com); omit for OpenID Connect providers', + }, + policyArns: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated ARNs of up to 10 IAM managed policies to use as session policies', + }, + policy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON IAM policy to further restrict session permissions (max 2048 chars)', + }, + durationSeconds: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Duration of the session in seconds (900-43200, default 3600)', + }, + }, + + request: { + url: '/api/tools/sts/assume-role-with-web-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + roleArn: params.roleArn, + roleSessionName: params.roleSessionName, + webIdentityToken: params.webIdentityToken, + providerId: params.providerId, + policyArns: params.policyArns, + policy: params.policy, + durationSeconds: params.durationSeconds, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to assume role with web identity') + } + + return { + success: true, + output: { + accessKeyId: data.accessKeyId ?? '', + secretAccessKey: data.secretAccessKey ?? '', + sessionToken: data.sessionToken ?? '', + expiration: data.expiration ?? null, + assumedRoleArn: data.assumedRoleArn ?? '', + assumedRoleId: data.assumedRoleId ?? '', + subjectFromWebIdentityToken: data.subjectFromWebIdentityToken ?? '', + audience: data.audience ?? null, + provider: data.provider ?? null, + packedPolicySize: data.packedPolicySize ?? null, + sourceIdentity: data.sourceIdentity ?? null, + }, + } + }, + + outputs: { + accessKeyId: { type: 'string', description: 'Temporary access key ID' }, + secretAccessKey: { type: 'string', description: 'Temporary secret access key' }, + sessionToken: { type: 'string', description: 'Temporary session token' }, + expiration: { type: 'string', description: 'Credential expiration timestamp', optional: true }, + assumedRoleArn: { type: 'string', description: 'ARN of the assumed role' }, + assumedRoleId: { type: 'string', description: 'Assumed role ID with session name' }, + subjectFromWebIdentityToken: { + type: 'string', + description: "Unique user identifier from the identity provider's token subject claim", + }, + audience: { + type: 'string', + description: 'Intended audience (client ID) of the web identity token', + optional: true, + }, + provider: { + type: 'string', + description: 'Issuing authority of the presented web identity token', + optional: true, + }, + packedPolicySize: { + type: 'number', + description: 'Percentage of allowed policy size used', + optional: true, + }, + sourceIdentity: { + type: 'string', + description: 'Source identity set on the role session, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/sts/index.ts b/apps/sim/tools/sts/index.ts index b691b7a64cf..2e4baa5f3a2 100644 --- a/apps/sim/tools/sts/index.ts +++ b/apps/sim/tools/sts/index.ts @@ -1,9 +1,13 @@ import { assumeRoleTool } from './assume_role' +import { assumeRoleWithSAMLTool } from './assume_role_with_saml' +import { assumeRoleWithWebIdentityTool } from './assume_role_with_web_identity' import { getAccessKeyInfoTool } from './get_access_key_info' import { getCallerIdentityTool } from './get_caller_identity' import { getSessionTokenTool } from './get_session_token' export const stsAssumeRoleTool = assumeRoleTool +export const stsAssumeRoleWithWebIdentityTool = assumeRoleWithWebIdentityTool +export const stsAssumeRoleWithSAMLTool = assumeRoleWithSAMLTool export const stsGetCallerIdentityTool = getCallerIdentityTool export const stsGetSessionTokenTool = getSessionTokenTool export const stsGetAccessKeyInfoTool = getAccessKeyInfoTool diff --git a/apps/sim/tools/sts/types.ts b/apps/sim/tools/sts/types.ts index 5676d972b5a..de0a1afa89c 100644 --- a/apps/sim/tools/sts/types.ts +++ b/apps/sim/tools/sts/types.ts @@ -14,6 +14,30 @@ export interface STSAssumeRoleParams extends STSConnectionConfig { externalId?: string | null serialNumber?: string | null tokenCode?: string | null + policyArns?: string | null + tags?: string | null + transitiveTagKeys?: string | null +} + +export interface STSAssumeRoleWithWebIdentityParams { + region: string + roleArn: string + roleSessionName: string + webIdentityToken: string + providerId?: string | null + policyArns?: string | null + policy?: string | null + durationSeconds?: number | null +} + +export interface STSAssumeRoleWithSAMLParams { + region: string + roleArn: string + principalArn: string + samlAssertion: string + policyArns?: string | null + policy?: string | null + durationSeconds?: number | null } export interface STSGetCallerIdentityParams extends STSConnectionConfig {} @@ -41,6 +65,40 @@ export interface STSAssumeRoleResponse extends ToolResponse { } } +export interface STSAssumeRoleWithWebIdentityResponse extends ToolResponse { + output: { + accessKeyId: string + secretAccessKey: string + sessionToken: string + expiration: string | null + assumedRoleArn: string + assumedRoleId: string + subjectFromWebIdentityToken: string + audience: string | null + provider: string | null + packedPolicySize: number | null + sourceIdentity: string | null + } +} + +export interface STSAssumeRoleWithSAMLResponse extends ToolResponse { + output: { + accessKeyId: string + secretAccessKey: string + sessionToken: string + expiration: string | null + assumedRoleArn: string + assumedRoleId: string + subject: string | null + subjectType: string | null + issuer: string | null + audience: string | null + nameQualifier: string | null + packedPolicySize: number | null + sourceIdentity: string | null + } +} + export interface STSGetCallerIdentityResponse extends ToolResponse { output: { account: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0135f8596d5..677077f158e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 887, - zodRoutes: 887, + totalRoutes: 904, + zodRoutes: 904, nonZodRoutes: 0, } as const