diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index c186ddf787a..edb16628481 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9500,7 +9500,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function NetSuiteIcon(props: SVGProps) { +export function OracleIcon(props: SVGProps) { return ( ) { ) } +export function NetSuiteIcon(props: SVGProps) { + return +} + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 096652e0900..74a82720af9 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -200,6 +200,7 @@ import { OktaIcon, OnePasswordIcon, OpenAIIcon, + OracleIcon, OtterIcon, OutlookIcon, PackageSearchIcon, @@ -543,6 +544,8 @@ export const blockTypeToIconMap: Record = { notion: NotionIcon, notion_v2: NotionIcon, obsidian: ObsidianIcon, + oci: OracleIcon, + oci_functions: OracleIcon, okta: OktaIcon, onedrive: MicrosoftOneDriveIcon, onepassword: OnePasswordIcon, diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index 16763637d19..b92a186d8f9 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -114,6 +114,11 @@ Update Credential (OAuth login or personal API key required) | `--auth-method ` | No | Provider authentication method. | | `--private-key ` | No | Write-only PEM private key. | | `--username ` | No | Provider run-as username. | +| `--tenancy-ocid ` | No | OCI tenancy OCID. | +| `--user-ocid ` | No | OCI user OCID. | +| `--fingerprint ` | No | OCI API-key fingerprint. | +| `--private-key-passphrase ` | No | Write-only OCI private-key passphrase. | +| `--region ` | No | OCI home region. | | `--name ` | No | Alias for --display-name. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index f7e2a05d058..9675a60c855 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -547,6 +547,11 @@ sim credentials update [options] | `--auth-method ` | No | Provider authentication method. | | `--private-key ` | No | Write-only PEM private key. | | `--username ` | No | Provider run-as username. | +| `--tenancy-ocid ` | No | OCI tenancy OCID. | +| `--user-ocid ` | No | OCI user OCID. | +| `--fingerprint ` | No | OCI API-key fingerprint. | +| `--private-key-passphrase ` | No | Write-only OCI private-key passphrase. | +| `--region ` | No | OCI home region. | | `--name ` | No | Alias for --display-name. | diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json index 0d1a85b2e55..ae3ec532a04 100644 --- a/apps/docs/content/docs/integrations/meta.json +++ b/apps/docs/content/docs/integrations/meta.json @@ -185,6 +185,7 @@ "notion", "notion-service-account", "obsidian", + "oci_functions", "okta", "onedrive", "onepassword", diff --git a/apps/docs/content/docs/integrations/oci_functions.mdx b/apps/docs/content/docs/integrations/oci_functions.mdx new file mode 100644 index 00000000000..78e729282ea --- /dev/null +++ b/apps/docs/content/docs/integrations/oci_functions.mdx @@ -0,0 +1,554 @@ +--- +title: OCI Functions +description: Invoke and manage Oracle Cloud Functions +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Use a reusable OCI API-key service account to invoke functions synchronously or in detached mode, discover applications and functions, and manage their configuration and existing container image references. Detached acknowledgements do not contain completed results. Image builds, registry uploads, and source deployment are outside this integration. + + + +## Actions + +### OCI Functions Invoke + +Invoke an OCI function synchronously, start a detached invocation, or dry-run without executing. Payloads and results are limited to 6 MB. Detached HTTP 202 acknowledges processing and is not a completed result. One provider invocation attempt per tool execution; keep block retries disabled to avoid replay. A lost response can follow successful execution; cancellation ends the wait, not the Oracle execution. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `functionId` | string | Yes | Function OCID; endpoint is discovered through an authenticated management read | +| `invocationType` | string | No | sync \(default\) or detached | +| `dryRun` | boolean | No | Validate invocation without executing the function \(default false\) | +| `intent` | string | No | Optional fn-intent: httprequest or cloudevent | +| `payloadType` | string | No | json \(default\), text, or file | +| `payload` | json | No | JSON value, or a string for text mode; omit for an empty body. JSON false, 0, null, and empty strings are preserved. | +| `file` | file | No | One uploaded UserFile for file mode; access is checked before download | +| `contentType` | string | No | Content type override; otherwise inferred from payload mode or uploaded file | +| `outputFormat` | string | No | auto \(default\) returns JSON/text or a file for binary data; file returns an execution file, except an empty body returns an empty string | +| `timeoutMs` | number | No | Client wait timeout in milliseconds, 1–300000 \(default 300000\); execution cancellation can end it earlier | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `functionId` | string | Invoked function OCID | +| `invocationType` | string | Requested invocation mode: sync or detached | +| `dryRun` | boolean | Whether this was a dry-run request | +| `accepted` | boolean | True for HTTP 202 acknowledgement; no completed result is available | +| `contentType` | string | Result content type, when a result is returned | +| `result` | json | Synchronous JSON value or text; empty response body becomes an empty string. Absent for acknowledgements, dry runs, or non-empty file output. | +| `file` | file | Binary or explicitly requested file result, stored as an execution file | + +### OCI Functions List Applications + +List one page of OCI Functions applications in a compartment, with exact filters and continuation-token pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `compartmentId` | string | Yes | Compartment OCID | +| `limit` | number | No | Page size, 1–50 \(default 10\) | +| `page` | string | No | Continuation token from nextPage; returns one page only | +| `displayName` | string | No | Exact display-name filter | +| `id` | string | No | Exact resource OCID filter | +| `lifecycleState` | string | No | Resource lifecycle state, such as ACTIVE; functions also support INACTIVE | +| `sortBy` | string | No | Sort by timeCreated, id, or displayName | +| `sortOrder` | string | No | ASC or DESC | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `nextPage` | string | Continuation token; absent on the last page | +| `applications` | array | One page of applications | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `subnetIds` | array | Application subnet OCIDs | +| ↳ `networkSecurityGroupIds` | array | Network security group OCIDs | +| ↳ `shape` | string | Application processor architecture | +| ↳ `traceConfig` | object | Application tracing configuration | +| ↳ `domainId` | string | APM domain OCID | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `logging` | object | Application logging configuration | +| ↳ `lineFormat` | string | JSON or PLAIN_TEXT | +| ↳ `imagePolicyConfig` | object | Image signature policy | +| ↳ `isPolicyEnabled` | boolean | Whether signature verification is enabled | +| ↳ `keyDetails` | array | Trusted signing keys | +| ↳ `kmsKeyId` | string | KMS key OCID | +| ↳ `securityAttributes` | json | Security attributes grouped by namespace, with value and mode | + +### OCI Functions Get Application + +Read an OCI Functions application, including configuration, networking, tags, tracing, logging, and image policy. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `applicationId` | string | Yes | Application OCID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `etag` | string | Oracle resource ETag for conditional updates or deletion | +| `application` | object | Documented OCI application metadata | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `subnetIds` | array | Application subnet OCIDs | +| ↳ `networkSecurityGroupIds` | array | Network security group OCIDs | +| ↳ `shape` | string | Application processor architecture | +| ↳ `traceConfig` | object | Application tracing configuration | +| ↳ `domainId` | string | APM domain OCID | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `logging` | object | Application logging configuration | +| ↳ `lineFormat` | string | JSON or PLAIN_TEXT | +| ↳ `imagePolicyConfig` | object | Image signature policy | +| ↳ `isPolicyEnabled` | boolean | Whether signature verification is enabled | +| ↳ `keyDetails` | array | Trusted signing keys | +| ↳ `kmsKeyId` | string | KMS key OCID | +| ↳ `securityAttributes` | json | Security attributes grouped by namespace, with value and mode | +| ↳ `config` | json | Application environment variables; functions can override values | +| ↳ `syslogUrl` | string | Application syslog URL | + +### OCI Functions Create Application + +Create an OCI Functions application using existing subnets. Does not create networking or deploy functions. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `compartmentId` | string | Yes | Compartment OCID | +| `displayName` | string | Yes | New application display name | +| `subnetIds` | array | Yes | At least one subnet OCID for the application | +| `shape` | string | No | GENERIC_X86, GENERIC_ARM, or GENERIC_X86_ARM; defaults according to Oracle | +| `configuration` | json | No | JSON settings: config \(string map; at most 100 entries/4096 UTF-8 bytes\), networkSecurityGroupIds \(at most 5\), syslogUrl, traceConfig \{domainId,isEnabled\}, logging \{lineFormat: JSON\|PLAIN_TEXT\}, freeformTags, definedTags, imagePolicyConfig \{isPolicyEnabled,keyDetails:\[\{kmsKeyId\}\]\}, securityAttributes \{namespace:\{key:\{value,mode: enforce\}\}\}. Supplied maps/objects replace existing values; omit to preserve. Use config:\{\} to clear configuration. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `etag` | string | Oracle resource ETag for conditional updates or deletion | +| `application` | object | Documented OCI application metadata | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `subnetIds` | array | Application subnet OCIDs | +| ↳ `networkSecurityGroupIds` | array | Network security group OCIDs | +| ↳ `shape` | string | Application processor architecture | +| ↳ `traceConfig` | object | Application tracing configuration | +| ↳ `domainId` | string | APM domain OCID | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `logging` | object | Application logging configuration | +| ↳ `lineFormat` | string | JSON or PLAIN_TEXT | +| ↳ `imagePolicyConfig` | object | Image signature policy | +| ↳ `isPolicyEnabled` | boolean | Whether signature verification is enabled | +| ↳ `keyDetails` | array | Trusted signing keys | +| ↳ `kmsKeyId` | string | KMS key OCID | +| ↳ `securityAttributes` | json | Security attributes grouped by namespace, with value and mode | +| ↳ `config` | json | Application environment variables; functions can override values | +| ↳ `syslogUrl` | string | Application syslog URL | + +### OCI Functions Update Application + +Update documented OCI Functions application settings. Supplied configuration maps replace existing values; omitted fields are unchanged. Does not rename the application or change its subnets. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `applicationId` | string | Yes | Application OCID | +| `configuration` | json | Yes | JSON settings: config \(string map; at most 100 entries/4096 UTF-8 bytes\), networkSecurityGroupIds \(at most 5\), syslogUrl, traceConfig \{domainId,isEnabled\}, logging \{lineFormat: JSON\|PLAIN_TEXT\}, freeformTags, definedTags, imagePolicyConfig \{isPolicyEnabled,keyDetails:\[\{kmsKeyId\}\]\}, securityAttributes \{namespace:\{key:\{value,mode: enforce\}\}\}. Supplied maps/objects replace existing values; omit to preserve. Use config:\{\} to clear configuration. | +| `ifMatch` | string | No | ETag from a resource read; reject the mutation if the resource changed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `etag` | string | Oracle resource ETag for conditional updates or deletion | +| `application` | object | Documented OCI application metadata | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `subnetIds` | array | Application subnet OCIDs | +| ↳ `networkSecurityGroupIds` | array | Network security group OCIDs | +| ↳ `shape` | string | Application processor architecture | +| ↳ `traceConfig` | object | Application tracing configuration | +| ↳ `domainId` | string | APM domain OCID | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `logging` | object | Application logging configuration | +| ↳ `lineFormat` | string | JSON or PLAIN_TEXT | +| ↳ `imagePolicyConfig` | object | Image signature policy | +| ↳ `isPolicyEnabled` | boolean | Whether signature verification is enabled | +| ↳ `keyDetails` | array | Trusted signing keys | +| ↳ `kmsKeyId` | string | KMS key OCID | +| ↳ `securityAttributes` | json | Security attributes grouped by namespace, with value and mode | +| ↳ `config` | json | Application environment variables; functions can override values | +| ↳ `syslogUrl` | string | Application syslog URL | + +### OCI Functions Delete Application + +Delete an OCI Functions application with one API request. Delete contained functions explicitly first; this tool does not perform cascading function deletion. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `applicationId` | string | Yes | Application OCID | +| `ifMatch` | string | No | ETag from a resource read; reject the mutation if the resource changed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `applicationId` | string | Application OCID supplied to the successful operation | + +### OCI Functions Change Application Compartment + +Move an OCI Functions application to a destination compartment. Oracle checks access for the move; verify destination policies before moving production applications. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `applicationId` | string | Yes | Application OCID | +| `compartmentId` | string | Yes | Destination compartment OCID | +| `ifMatch` | string | No | ETag from a resource read; reject the mutation if the resource changed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `applicationId` | string | Application OCID supplied to the successful operation | +| `compartmentId` | string | Destination compartment OCID supplied to the successful move | + +### OCI Functions List Functions + +List one page of OCI functions in an application, with exact filters and continuation-token pagination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `applicationId` | string | Yes | Application OCID | +| `limit` | number | No | Page size, 1–50 \(default 10\) | +| `page` | string | No | Continuation token from nextPage; returns one page only | +| `displayName` | string | No | Exact display-name filter | +| `id` | string | No | Exact resource OCID filter | +| `lifecycleState` | string | No | Resource lifecycle state, such as ACTIVE; functions also support INACTIVE | +| `sortBy` | string | No | Sort by timeCreated, id, or displayName | +| `sortOrder` | string | No | ASC or DESC | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `nextPage` | string | Continuation token; absent on the last page | +| `functions` | array | One page of functions | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `applicationId` | string | Application OCID | +| ↳ `image` | string | Container image reference | +| ↳ `imageDigest` | string | Container image digest | +| ↳ `invokeEndpoint` | string | Oracle invocation endpoint; the invoke tool discovers and validates it automatically | +| ↳ `memoryInMBs` | number | Allocated memory in MB | +| ↳ `timeoutInSeconds` | number | Synchronous execution timeout in seconds | +| ↳ `detachedModeTimeoutInSeconds` | number | Detached execution timeout in seconds | +| ↳ `shape` | string | Function processor architecture | +| ↳ `provisionedConcurrencyConfig` | object | Provisioned concurrency settings | +| ↳ `strategy` | string | NONE or CONSTANT | +| ↳ `count` | number | Provisioned concurrency count for CONSTANT | +| ↳ `failureDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `successDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `traceConfig` | object | Function tracing configuration | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `sourceDetails` | object | Pre-built function source metadata, when present | +| ↳ `sourceType` | string | PRE_BUILT_FUNCTIONS | +| ↳ `pbfListingId` | string | Pre-built function listing OCID | + +### OCI Functions Get Function + +Read an OCI function, including its image, configuration, execution timeouts, concurrency, destinations, and invocation endpoint. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `functionId` | string | Yes | Function OCID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `etag` | string | Oracle resource ETag for conditional updates or deletion | +| `function` | object | Documented OCI function metadata | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `applicationId` | string | Application OCID | +| ↳ `image` | string | Container image reference | +| ↳ `imageDigest` | string | Container image digest | +| ↳ `invokeEndpoint` | string | Oracle invocation endpoint; the invoke tool discovers and validates it automatically | +| ↳ `memoryInMBs` | number | Allocated memory in MB | +| ↳ `timeoutInSeconds` | number | Synchronous execution timeout in seconds | +| ↳ `detachedModeTimeoutInSeconds` | number | Detached execution timeout in seconds | +| ↳ `shape` | string | Function processor architecture | +| ↳ `provisionedConcurrencyConfig` | object | Provisioned concurrency settings | +| ↳ `strategy` | string | NONE or CONSTANT | +| ↳ `count` | number | Provisioned concurrency count for CONSTANT | +| ↳ `failureDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `successDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `traceConfig` | object | Function tracing configuration | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `sourceDetails` | object | Pre-built function source metadata, when present | +| ↳ `sourceType` | string | PRE_BUILT_FUNCTIONS | +| ↳ `pbfListingId` | string | Pre-built function listing OCID | +| ↳ `config` | json | Function environment variables, overriding application values | + +### OCI Functions Create Function + +Create an OCI function from an existing container image. Does not build containers, upload images, or deploy source code. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `applicationId` | string | Yes | Application OCID | +| `displayName` | string | Yes | New function display name | +| `image` | string | Yes | Existing image reference in OCI Registry in the function region | +| `memoryInMBs` | number | Yes | Function memory: 128, 256, 512, 1024, 2048, or 3072 MB | +| `configuration` | json | No | JSON settings: config \(string map; effective application plus function configuration must fit 4096 UTF-8 bytes\), imageDigest, timeoutInSeconds \(1–300\), detachedModeTimeoutInSeconds \(5–3600\), provisionedConcurrencyConfig \{strategy: NONE\} or \{strategy: CONSTANT,count\}, successDestination/failureDestination \{destinationType: NONE\|STREAM\|QUEUE\|NOTIFICATION, streamId\|queueId\|topicId, optional channelId for QUEUE\}, traceConfig \{isEnabled\}, freeformTags, definedTags. Supplied maps/objects replace existing values; omit to preserve. Use config:\{\} to clear function overrides. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `etag` | string | Oracle resource ETag for conditional updates or deletion | +| `function` | object | Documented OCI function metadata | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `applicationId` | string | Application OCID | +| ↳ `image` | string | Container image reference | +| ↳ `imageDigest` | string | Container image digest | +| ↳ `invokeEndpoint` | string | Oracle invocation endpoint; the invoke tool discovers and validates it automatically | +| ↳ `memoryInMBs` | number | Allocated memory in MB | +| ↳ `timeoutInSeconds` | number | Synchronous execution timeout in seconds | +| ↳ `detachedModeTimeoutInSeconds` | number | Detached execution timeout in seconds | +| ↳ `shape` | string | Function processor architecture | +| ↳ `provisionedConcurrencyConfig` | object | Provisioned concurrency settings | +| ↳ `strategy` | string | NONE or CONSTANT | +| ↳ `count` | number | Provisioned concurrency count for CONSTANT | +| ↳ `failureDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `successDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `traceConfig` | object | Function tracing configuration | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `sourceDetails` | object | Pre-built function source metadata, when present | +| ↳ `sourceType` | string | PRE_BUILT_FUNCTIONS | +| ↳ `pbfListingId` | string | Pre-built function listing OCID | +| ↳ `config` | json | Function environment variables, overriding application values | + +### OCI Functions Update Function + +Update an OCI function image, memory, or documented configuration. Supplied maps and nested settings replace existing values; omitted fields are unchanged. Does not build or publish images. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `functionId` | string | Yes | Function OCID | +| `image` | string | No | Existing image reference in OCI Registry in the function region | +| `memoryInMBs` | number | No | Function memory: 128, 256, 512, 1024, 2048, or 3072 MB | +| `configuration` | json | No | JSON settings: config \(string map; effective application plus function configuration must fit 4096 UTF-8 bytes\), imageDigest, timeoutInSeconds \(1–300\), detachedModeTimeoutInSeconds \(5–3600\), provisionedConcurrencyConfig \{strategy: NONE\} or \{strategy: CONSTANT,count\}, successDestination/failureDestination \{destinationType: NONE\|STREAM\|QUEUE\|NOTIFICATION, streamId\|queueId\|topicId, optional channelId for QUEUE\}, traceConfig \{isEnabled\}, freeformTags, definedTags. Supplied maps/objects replace existing values; omit to preserve. Use config:\{\} to clear function overrides. | +| `ifMatch` | string | No | ETag from a resource read; reject the mutation if the resource changed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `etag` | string | Oracle resource ETag for conditional updates or deletion | +| `function` | object | Documented OCI function metadata | +| ↳ `id` | string | Resource OCID | +| ↳ `compartmentId` | string | Compartment OCID | +| ↳ `displayName` | string | Resource display name | +| ↳ `lifecycleState` | string | Current lifecycle state | +| ↳ `timeCreated` | string | Creation time in RFC 3339 format | +| ↳ `timeUpdated` | string | Last update time in RFC 3339 format | +| ↳ `freeformTags` | json | Free-form tag key/value map | +| ↳ `definedTags` | json | Defined tags grouped by namespace | +| ↳ `applicationId` | string | Application OCID | +| ↳ `image` | string | Container image reference | +| ↳ `imageDigest` | string | Container image digest | +| ↳ `invokeEndpoint` | string | Oracle invocation endpoint; the invoke tool discovers and validates it automatically | +| ↳ `memoryInMBs` | number | Allocated memory in MB | +| ↳ `timeoutInSeconds` | number | Synchronous execution timeout in seconds | +| ↳ `detachedModeTimeoutInSeconds` | number | Detached execution timeout in seconds | +| ↳ `shape` | string | Function processor architecture | +| ↳ `provisionedConcurrencyConfig` | object | Provisioned concurrency settings | +| ↳ `strategy` | string | NONE or CONSTANT | +| ↳ `count` | number | Provisioned concurrency count for CONSTANT | +| ↳ `failureDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `successDestination` | object | Invocation result destination; fields depend on destinationType | +| ↳ `destinationType` | string | NONE, STREAM, QUEUE, or NOTIFICATION | +| ↳ `streamId` | string | Stream OCID for STREAM | +| ↳ `queueId` | string | Queue OCID for QUEUE | +| ↳ `channelId` | string | Optional queue channel | +| ↳ `topicId` | string | Topic OCID for NOTIFICATION | +| ↳ `traceConfig` | object | Function tracing configuration | +| ↳ `isEnabled` | boolean | Whether tracing is enabled | +| ↳ `sourceDetails` | object | Pre-built function source metadata, when present | +| ↳ `sourceType` | string | PRE_BUILT_FUNCTIONS | +| ↳ `pbfListingId` | string | Pre-built function listing OCID | +| ↳ `config` | json | Function environment variables, overriding application values | + +### OCI Functions Delete Function + +Delete an OCI function, optionally requiring an ETag match. Returns successful HTTP 204 acknowledgement without parsing an empty response body. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `oauthCredential` | string | Yes | Reusable OCI API-key service-account credential ID | +| `region` | string | No | OCI region identifier; omit to use the credential region | +| `functionId` | string | Yes | Function OCID | +| `ifMatch` | string | No | ETag from a resource read; reject the mutation if the resource changed | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | number | HTTP status returned by Oracle | +| `opcRequestId` | string | Oracle request identifier, when returned | +| `functionId` | string | Function OCID supplied to the successful operation | + + diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 0792befdee4..81bb64fff2d 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -13928,6 +13928,36 @@ "type": "string", "minLength": 1, "maxLength": 255 + }, + "tenancyOcid": { + "description": "OCI tenancy OCID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "userOcid": { + "description": "OCI user OCID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "fingerprint": { + "description": "OCI API-key fingerprint.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "privateKeyPassphrase": { + "description": "Write-only OCI private-key passphrase.", + "writeOnly": true, + "type": "string", + "maxLength": 4096 + }, + "region": { + "description": "OCI home region.", + "type": "string", + "minLength": 1, + "maxLength": 128 } }, "additionalProperties": false, diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index b888d17061b..d9f696b45ec 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -345,4 +345,68 @@ describe('POST /api/credentials', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) + + it('forwards OCI API-key fields without returning secret material', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValueOnce({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'encrypted-oci-blob', + displayName: 'ocid1.user.oc1..principal', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..principal', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..principal' }, + }) + queueTableRows(credential, []) + queueTableRows(credential, []) + queueTableRows(credential, [ + { + id: 'credential-oci', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'ocid1.user.oc1..principal', + description: null, + unredacted: false, + providerId: 'oci-api-key-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-oci-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + ]) + + const response = await POST( + createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }) + ) + const body = await response.text() + + expect(response.status).toBe(201) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }) + ) + expect(body).not.toContain('PRIVATE KEY') + expect(body).not.toContain('exact passphrase') + expect(body).not.toContain('encrypted-oci-blob') + }) }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts index 74ef186d814..eac94e9ec6b 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -121,6 +121,33 @@ describe('PATCH /api/v2/credentials/[credentialId]', () => { expect(body).not.toContain('MUST_NOT_LEAK_CIPHERTEXT') }) + it('forwards a complete OCI rotation tuple with an omitted replacement passphrase', async () => { + const request = patchRequest({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nreplacement\n-----END PRIVATE KEY-----', + region: 'us-ashburn-1', + }) + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nreplacement\n-----END PRIVATE KEY-----', + region: 'us-ashburn-1', + credentialId: CREDENTIAL_ID, + assertedWorkspaceId: WORKSPACE_ID, + }, + request, + }) + expect(JSON.stringify(await response.json())).not.toContain('PRIVATE KEY') + }) + it('clears a description with an explicit null and leaves an omitted field alone', async () => { await PATCH(patchRequest({ description: null }), context) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 8422b401e6b..945d93a8c39 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -203,11 +203,55 @@ describe('POST /api/v2/credentials', () => { authMethod: undefined, privateKey: undefined, username: undefined, + tenancyOcid: undefined, + userOcid: undefined, + fingerprint: undefined, + privateKeyPassphrase: undefined, + region: undefined, }, request, }) }) + it('forwards OCI credential fields from the write-only credentials envelope', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'oci-api-key-service-account', + credentials: JSON.stringify({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }), + }), + }) + const response = await POST(request) + const body = await response.text() + + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: expect.objectContaining({ + providerId: 'oci-api-key-service-account', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..user', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' exact passphrase ', + region: 'us-ashburn-1', + }), + request, + }) + expect(body).not.toContain('PRIVATE KEY') + expect(body).not.toContain('exact passphrase') + }) + /** * `credentials create slack-custom-bot` used to fail twice over: discovery * demanded a client-generated id, and a caller who then supplied a slug was diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index d8ae8a16521..7b7738518e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -31,6 +31,7 @@ import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal' @@ -48,6 +49,7 @@ export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof SLACK_CUSTOM_BOT_PROVIDER_ID + | typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID | TokenServiceAccountProviderId | ClientCredentialAccountProviderId @@ -55,6 +57,8 @@ export type ServiceAccountProviderId = const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/atlassian-service-account' +const OCI_API_KEY_DOCS_URL = + 'https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm' function openDocs(url: string): void { window.open(url, '_blank', 'noopener,noreferrer') @@ -143,6 +147,8 @@ interface ConnectServiceAccountModalProps { * - `atlassian-service-account`: API token + site domain. Validated by the * server against the Atlassian API; user-facing errors are mapped from the * route's `error.code`. + * - `oci-api-key-service-account`: API signing-key fields. Validated locally + * and with a bounded OCI request before encrypted storage. */ export function ConnectServiceAccountModal({ open, @@ -227,6 +233,23 @@ export function ConnectServiceAccountModal({ /> ) } + if (serviceAccountProviderId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + return ( + + ) + } return ( void } +function OciApiKeyServiceAccountModal({ + open, + onOpenChange, + workspaceId, + organizationId, + serviceName, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, + onCreated, +}: ProviderModalProps) { + const [tenancyOcid, setTenancyOcid] = useState('') + const [userOcid, setUserOcid] = useState('') + const [fingerprint, setFingerprint] = useState('') + const [privateKey, setPrivateKey] = useState('') + const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('') + const [region, setRegion] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + const createCredential = useCreateScopedCredential() + const updateCredential = useUpdateScopedCredential() + + const isPending = createCredential.isPending || updateCredential.isPending + const isDisabled = + !tenancyOcid.trim() || + !userOcid.trim() || + !fingerprint.trim() || + !privateKey.trim() || + !region.trim() || + isPending + + const clearError = () => { + if (error) setError(null) + } + + const handleSubmit = async () => { + setError(null) + if (isDisabled) return + const fields = { + tenancyOcid: tenancyOcid.trim(), + userOcid: userOcid.trim(), + fingerprint: fingerprint.trim(), + privateKey, + ...(privateKeyPassphrase.length > 0 ? { privateKeyPassphrase } : {}), + region: region.trim(), + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + } + try { + let connectedCredentialId = credentialId + if (credentialId) { + await updateCredential.mutateAsync({ + ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), + credentialId, + ...fields, + }) + } else { + const created = await createCredential.mutateAsync({ + ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), + type: 'service_account', + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + ...fields, + }) + connectedCredentialId = created.credential.id + } + if (connectedCredentialId) onCreated?.(connectedCredentialId) + onOpenChange(false) + } catch (err: unknown) { + setError(getErrorMessage(err, 'Failed to add OCI API-key credential')) + logger.error('Failed to add OCI API-key credential', err) + } + } + + return ( + + onOpenChange(false)}> + Add {serviceName} API key + + + { + setTenancyOcid(value) + clearError() + }} + placeholder='ocid1.tenancy.oc1..' + autoComplete='off' + mono + required + /> + { + setUserOcid(value) + clearError() + }} + placeholder='ocid1.user.oc1..' + autoComplete='off' + mono + required + /> + { + setFingerprint(value) + clearError() + }} + placeholder='00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff' + autoComplete='off' + mono + required + /> + { + setPrivateKey(value) + clearError() + }} + placeholder='-----BEGIN PRIVATE KEY-----' + minHeight={120} + className={ + privateKey ? '[&_textarea:not(:focus)]:[-webkit-text-security:disc]' : undefined + } + mono + required + /> + { + setPrivateKeyPassphrase(value) + clearError() + }} + placeholder='Optional' + autoComplete='new-password' + /> + { + setRegion(value) + clearError() + }} + placeholder='us-ashburn-1' + autoComplete='off' + mono + required + /> + + + {error} + + onOpenChange(false)} + secondaryActions={[{ label: 'Setup guide', onClick: () => openDocs(OCI_API_KEY_DOCS_URL) }]} + primaryAction={{ + label: isPending ? 'Adding...' : credentialId ? 'Reconnect' : 'Add API key', + onClick: handleSubmit, + disabled: isDisabled, + }} + /> + + ) +} + /** * Google service-account flow. Accepts the raw JSON key (paste or drag/drop) * and validates against the shared `serviceAccountJsonSchema` so the same diff --git a/apps/sim/blocks/blocks/oci_functions.test.ts b/apps/sim/blocks/blocks/oci_functions.test.ts new file mode 100644 index 00000000000..c9c63ab0a8e --- /dev/null +++ b/apps/sim/blocks/blocks/oci_functions.test.ts @@ -0,0 +1,107 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { OciFunctionsBlock } from '@/blocks/blocks/oci_functions' + +function map(input: Record) { + return { ...input, ...OciFunctionsBlock.tools.config?.params?.(input) } +} + +/** Raw block values are merged underneath the mapper patch by the executor. */ +describe('OCI Functions execution-time parameter mapping', () => { + it('clears stale invocation fields when changing operations', () => { + const result = map({ + operation: 'get_function', + oauthCredential: 'credential-1', + functionId: 'function-1', + payload: '{broken', + file: { key: 'old-file' }, + dryRun: true, + invocationType: 'detached', + configuration: '{broken', + applicationId: 'stale-app', + }) + expect(result.functionId).toBe('function-1') + for (const key of [ + 'payload', + 'file', + 'dryRun', + 'invocationType', + 'configuration', + 'applicationId', + ]) { + expect(result[key]).toBeUndefined() + } + }) + + it('preserves a manual canonical function OCID without discovery-only scope', () => { + const result = map({ + operation: 'invoke', + oauthCredential: 'credential-1', + functionId: 'known-function', + ociRegion: 'credential', + }) + expect(result.functionId).toBe('known-function') + expect(result.applicationId).toBeUndefined() + expect(result.compartmentId).toBeUndefined() + expect(result.region).toBeUndefined() + }) + + it('maps the selector region into the service request region', () => { + expect(map({ operation: 'get_function', ociRegion: 'us-phoenix-1' }).region).toBe( + 'us-phoenix-1' + ) + }) + + it('coerces resolved numeric, boolean, and JSON values only at execution time', () => { + const result = map({ + operation: 'invoke', + payloadType: 'json', + payload: 'false', + dryRun: 'false', + timeoutMs: '120000', + }) + expect(result).toMatchObject({ payload: false, dryRun: false, timeoutMs: 120000 }) + expect( + map({ operation: 'create_function', memoryInMBs: '512', configuration: '{"config":{}}' }) + ).toMatchObject({ memoryInMBs: 512, configuration: { config: {} } }) + expect(map({ operation: 'create_application', subnetIds: '["subnet-1"]' }).subnetIds).toEqual([ + 'subnet-1', + ]) + }) + + it.each(['false', '0', 'null', '""'])('preserves JSON value %s', (payload) => { + expect(map({ operation: 'invoke', payloadType: 'json', payload }).payload).toEqual( + JSON.parse(payload) + ) + }) + + it('keeps text unencoded and clears file data in text mode', () => { + const result = map({ + operation: 'invoke', + payloadType: 'text', + payload: 'plain text', + file: { key: 'stale' }, + }) + expect(result.payload).toBe('plain text') + expect(result.file).toBeUndefined() + }) + + it('normalizes a single file and rejects multiple files', () => { + const file = { key: 'file-1', name: 'input.bin', size: 2 } + const result = map({ operation: 'invoke', payloadType: 'file', file: [file], payload: 'stale' }) + expect(result.file).toEqual(file) + expect(result.payload).toBeUndefined() + expect(() => map({ operation: 'invoke', payloadType: 'file', file: [file, file] })).toThrow() + }) + + it('rejects malformed JSON and numbers before provider execution', () => { + expect(() => map({ operation: 'invoke', payloadType: 'json', payload: '{' })).toThrow( + 'Payload must be valid JSON' + ) + expect(() => map({ operation: 'list_applications', limit: '51' })).toThrow() + expect(() => map({ operation: 'invoke', dryRun: 'maybe' })).toThrow( + 'Dry run must be true or false' + ) + expect(() => map({ operation: 'unknown' })).toThrow('Invalid OCI Functions operation') + }) +}) diff --git a/apps/sim/blocks/blocks/oci_functions.ts b/apps/sim/blocks/blocks/oci_functions.ts new file mode 100644 index 00000000000..afaf80b412d --- /dev/null +++ b/apps/sim/blocks/blocks/oci_functions.ts @@ -0,0 +1,839 @@ +import { OracleIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput, parseOptionalNumberInput } from '@/blocks/utils' +import type { OciFunctionsResponse } from '@/tools/oci_functions/types' + +const OPERATION_PARAMS = { + invoke: [ + 'functionId', + 'invocationType', + 'dryRun', + 'intent', + 'payloadType', + 'payload', + 'file', + 'contentType', + 'outputFormat', + 'timeoutMs', + ], + list_applications: [ + 'compartmentId', + 'limit', + 'page', + 'displayName', + 'id', + 'lifecycleState', + 'sortBy', + 'sortOrder', + ], + get_application: ['applicationId'], + create_application: ['compartmentId', 'displayName', 'subnetIds', 'shape', 'configuration'], + update_application: ['applicationId', 'configuration', 'ifMatch'], + delete_application: ['applicationId', 'ifMatch'], + change_application_compartment: ['applicationId', 'compartmentId', 'ifMatch'], + list_functions: [ + 'applicationId', + 'limit', + 'page', + 'displayName', + 'id', + 'lifecycleState', + 'sortBy', + 'sortOrder', + ], + get_function: ['functionId'], + create_function: ['applicationId', 'displayName', 'image', 'memoryInMBs', 'configuration'], + update_function: ['functionId', 'image', 'memoryInMBs', 'configuration', 'ifMatch'], + delete_function: ['functionId', 'ifMatch'], +} as const + +type Operation = keyof typeof OPERATION_PARAMS +function operation(value: unknown): Operation { + if (typeof value !== 'string' || !Object.hasOwn(OPERATION_PARAMS, value)) { + throw new Error('Invalid OCI Functions operation') + } + return value as Operation +} + +function parseJson(value: unknown, label: string): unknown { + if (value === undefined || value === '') return undefined + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + throw new Error(`${label} must be valid JSON`) + } +} + +function optionalBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') return undefined + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + throw new Error('Dry run must be true or false') +} + +export const OciFunctionsBlock: BlockConfig = { + type: 'oci_functions', + name: 'OCI Functions', + description: 'Invoke and manage Oracle Cloud Functions', + longDescription: + 'Use a reusable OCI API-key service account to invoke functions synchronously or in detached mode, discover applications and functions, and manage their configuration and existing container image references. Detached acknowledgements do not contain completed results. Image builds, registry uploads, and source deployment are outside this integration.', + docsLink: 'https://docs.sim.ai/integrations/oci_functions', + authMode: AuthMode.ApiKey, + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: '#FFFFFF', + icon: OracleIcon, + canvasPresentation: { + defaultTitle: 'OCI Functions', + sentences: { + byOperation: { + invoke: [{ text: 'Invoke', field: ['functionSelector', 'functionManual'], core: true }], + list_applications: [{ text: 'List applications in', field: 'compartmentId', core: true }], + get_application: [ + { text: 'Read', field: ['applicationSelector', 'applicationManual'], core: true }, + ], + create_application: [ + { text: 'Create application', field: 'displayName', core: true }, + { text: 'in', field: 'compartmentId', core: true }, + ], + update_application: [ + { text: 'Update', field: ['applicationSelector', 'applicationManual'], core: true }, + ], + delete_application: [ + { text: 'Delete', field: ['applicationSelector', 'applicationManual'], core: true }, + ], + change_application_compartment: [ + { text: 'Move', field: ['applicationSelector', 'applicationManual'], core: true }, + { text: 'to', field: 'compartmentId', core: true }, + ], + list_functions: [ + { + text: 'List functions in', + field: ['applicationSelector', 'applicationManual'], + core: true, + }, + ], + get_function: [{ text: 'Read', field: ['functionSelector', 'functionManual'], core: true }], + create_function: [ + { text: 'Create function', field: 'displayName', core: true }, + { text: 'in', field: ['applicationSelector', 'applicationManual'], core: true }, + ], + update_function: [ + { text: 'Update', field: ['functionSelector', 'functionManual'], core: true }, + ], + delete_function: [ + { text: 'Delete', field: ['functionSelector', 'functionManual'], core: true }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'credential', + title: 'OCI Account', + type: 'oauth-input', + serviceId: 'oci-functions', + credentialKind: 'service-account', + canonicalParamId: 'oauthCredential', + mode: 'basic', + required: true, + placeholder: 'Select OCI credential', + }, + { + id: 'manualCredential', + title: 'OCI Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + required: true, + placeholder: 'Enter credential ID', + }, + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + required: true, + value: () => 'list_applications', + options: [ + { id: 'invoke', label: 'Invoke' }, + { id: 'list_applications', label: 'List Applications' }, + { id: 'get_application', label: 'Get Application' }, + { id: 'create_application', label: 'Create Application' }, + { id: 'update_application', label: 'Update Application' }, + { id: 'delete_application', label: 'Delete Application' }, + { id: 'change_application_compartment', label: 'Change Application Compartment' }, + { id: 'list_functions', label: 'List Functions' }, + { id: 'get_function', label: 'Get Function' }, + { id: 'create_function', label: 'Create Function' }, + { id: 'update_function', label: 'Update Function' }, + { id: 'delete_function', label: 'Delete Function' }, + ], + }, + { + id: 'ociRegion', + title: 'Region', + type: 'short-input', + value: () => 'credential', + placeholder: 'credential or us-ashburn-1', + description: 'Use credential for its configured region, or enter an OCI region identifier.', + }, + { + id: 'compartmentId', + title: 'Compartment', + type: 'short-input', + condition: { + field: 'operation', + value: ['list_applications', 'create_application', 'change_application_compartment'], + }, + required: true, + description: + 'Compartment to list or create in, or the destination for Change Application Compartment.', + placeholder: 'ocid1.compartment...', + }, + { + id: 'applicationCompartmentId', + title: 'Application Compartment', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'get_application', + 'update_application', + 'delete_application', + 'change_application_compartment', + 'list_functions', + 'create_function', + 'invoke', + 'get_function', + 'update_function', + 'delete_function', + ], + }, + mode: 'basic', + description: + 'Used only to discover applications. In advanced mode, enter the known application or function OCID directly.', + placeholder: 'Compartment containing the application', + }, + { + id: 'applicationSelector', + title: 'Application', + type: 'project-selector', + condition: { + field: 'operation', + value: [ + 'get_application', + 'update_application', + 'delete_application', + 'change_application_compartment', + 'list_functions', + 'create_function', + 'invoke', + 'get_function', + 'update_function', + 'delete_function', + ], + }, + required: { + field: 'operation', + value: [ + 'get_application', + 'update_application', + 'delete_application', + 'change_application_compartment', + 'list_functions', + 'create_function', + ], + }, + mode: 'basic', + canonicalParamId: 'applicationId', + serviceId: 'oci-functions', + selectorKey: 'oci-functions.applications', + dependsOn: ['credential', 'ociRegion', 'applicationCompartmentId'], + placeholder: 'Select application', + }, + { + id: 'applicationManual', + title: 'Application', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'get_application', + 'update_application', + 'delete_application', + 'change_application_compartment', + 'list_functions', + 'create_function', + 'invoke', + 'get_function', + 'update_function', + 'delete_function', + ], + }, + required: { + field: 'operation', + value: [ + 'get_application', + 'update_application', + 'delete_application', + 'change_application_compartment', + 'list_functions', + 'create_function', + ], + }, + mode: 'advanced', + canonicalParamId: 'applicationId', + placeholder: 'ocid1.fnapp...', + }, + { + id: 'functionSelector', + title: 'Function', + type: 'project-selector', + condition: { + field: 'operation', + value: ['invoke', 'get_function', 'update_function', 'delete_function'], + }, + required: true, + mode: 'basic', + canonicalParamId: 'functionId', + serviceId: 'oci-functions', + selectorKey: 'oci-functions.functions', + dependsOn: ['credential', 'ociRegion', 'applicationSelector'], + placeholder: 'Select function', + }, + { + id: 'functionManual', + title: 'Function', + type: 'short-input', + condition: { + field: 'operation', + value: ['invoke', 'get_function', 'update_function', 'delete_function'], + }, + required: true, + mode: 'advanced', + canonicalParamId: 'functionId', + placeholder: 'ocid1.fnfunc...', + }, + { + id: 'displayName', + title: 'Display Name', + type: 'short-input', + condition: { + field: 'operation', + value: ['list_applications', 'list_functions', 'create_application', 'create_function'], + }, + required: { field: 'operation', value: ['create_application', 'create_function'] }, + description: 'New resource name for creation, or an exact display-name filter when listing.', + }, + { + id: 'subnetIds', + title: 'Subnet OCIDs', + type: 'code', + condition: { field: 'operation', value: ['create_application'] }, + required: true, + language: 'json', + placeholder: '["ocid1.subnet..."]', + wandConfig: { + enabled: true, + generationType: 'json-array', + prompt: 'Use the supplied OCI subnet OCIDs. Return ONLY the JSON array.', + }, + }, + { + id: 'shape', + title: 'Architecture', + type: 'dropdown', + condition: { field: 'operation', value: ['create_application'] }, + mode: 'advanced', + options: [ + { id: 'GENERIC_X86', label: 'x86' }, + { id: 'GENERIC_ARM', label: 'Arm' }, + { id: 'GENERIC_X86_ARM', label: 'x86 and Arm' }, + ], + }, + { + id: 'image', + title: 'Container Image', + type: 'short-input', + condition: { field: 'operation', value: ['create_function', 'update_function'] }, + required: { field: 'operation', value: ['create_function'] }, + description: + 'Reference an existing image in OCI Registry in the function region. The integration does not build or upload it.', + placeholder: 'iad.ocir.io/namespace/repository:tag', + }, + { + id: 'memoryInMBs', + title: 'Memory (MB)', + type: 'short-input', + condition: { field: 'operation', value: ['create_function', 'update_function'] }, + required: { field: 'operation', value: ['create_function'] }, + placeholder: '128, 256, 512, 1024, 2048, or 3072', + }, + { + id: 'configuration', + title: 'Configuration', + type: 'code', + condition: { + field: 'operation', + value: ['create_application', 'update_application', 'create_function', 'update_function'], + }, + required: { field: 'operation', value: ['update_application'] }, + description: + 'Documented settings object. Supplied maps replace existing values; omit fields to preserve them. Use {"config":{}} to clear configuration. Advanced settings include tags, tracing, concurrency, and destinations; see tool documentation.', + language: 'json', + placeholder: '{"config":{"ENV":"production"}}', + wandConfig: { + enabled: true, + generationType: 'json-object', + prompt: + 'Generate documented OCI Functions settings for the selected application or function operation. Supplied maps replace existing values; omit fields that should stay unchanged. Return ONLY the JSON object.', + }, + }, + { + id: 'ifMatch', + title: 'If Match (ETag)', + type: 'short-input', + condition: { + field: 'operation', + value: [ + 'update_application', + 'delete_application', + 'change_application_compartment', + 'update_function', + 'delete_function', + ], + }, + mode: 'advanced', + description: + 'Require the ETag returned by Get Application or Get Function to match before changing the resource.', + }, + { + id: 'limit', + title: 'Page Size', + type: 'short-input', + condition: { field: 'operation', value: ['list_applications', 'list_functions'] }, + mode: 'advanced', + placeholder: '10 (maximum 50)', + }, + { + id: 'page', + title: 'Page Token', + type: 'short-input', + condition: { field: 'operation', value: ['list_applications', 'list_functions'] }, + mode: 'advanced', + }, + { + id: 'id', + title: 'Resource OCID Filter', + type: 'short-input', + condition: { field: 'operation', value: ['list_applications', 'list_functions'] }, + mode: 'advanced', + }, + { + id: 'lifecycleState', + title: 'Lifecycle State', + type: 'short-input', + condition: { field: 'operation', value: ['list_applications', 'list_functions'] }, + mode: 'advanced', + placeholder: 'ACTIVE', + description: 'Resource lifecycle state. Function lists also support INACTIVE.', + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + condition: { field: 'operation', value: ['list_applications', 'list_functions'] }, + mode: 'advanced', + options: [ + { id: 'timeCreated', label: 'timeCreated' }, + { id: 'id', label: 'id' }, + { id: 'displayName', label: 'displayName' }, + ], + }, + { + id: 'sortOrder', + title: 'Sort Order', + type: 'dropdown', + condition: { field: 'operation', value: ['list_applications', 'list_functions'] }, + mode: 'advanced', + options: [ + { id: 'ASC', label: 'ASC' }, + { id: 'DESC', label: 'DESC' }, + ], + }, + { + id: 'invocationType', + title: 'Invocation Mode', + type: 'dropdown', + condition: { field: 'operation', value: ['invoke'] }, + description: + 'Detached acknowledgement means processing was accepted, not completed. Configure Oracle success/failure destinations for detached results.', + value: () => 'sync', + options: [ + { id: 'sync', label: 'Synchronous' }, + { id: 'detached', label: 'Detached' }, + ], + }, + { + id: 'payloadType', + title: 'Payload Type', + type: 'dropdown', + condition: { field: 'operation', value: ['invoke'] }, + value: () => 'json', + options: [ + { id: 'json', label: 'JSON' }, + { id: 'text', label: 'Text' }, + { id: 'file', label: 'File' }, + ], + }, + { + id: 'payload', + title: 'Payload', + type: 'long-input', + condition: { + field: 'operation', + value: 'invoke', + and: { field: 'payloadType', value: ['json', 'text'] }, + }, + placeholder: '{"message":"hello"}', + description: 'JSON value or plain text, up to 6 MB. Leave empty for an empty body.', + }, + { + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + condition: { + field: 'operation', + value: 'invoke', + and: { field: 'payloadType', value: 'file' }, + }, + mode: 'basic', + multiple: false, + maxSize: 6, + required: true, + placeholder: 'Upload one file (up to 6 MB)', + }, + { + id: 'fileReference', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + condition: { + field: 'operation', + value: 'invoke', + and: { field: 'payloadType', value: 'file' }, + }, + mode: 'advanced', + required: true, + placeholder: 'Reference an uploaded file', + }, + { + id: 'dryRun', + title: 'Dry Run', + type: 'switch', + condition: { field: 'operation', value: ['invoke'] }, + mode: 'advanced', + description: 'Validate the invocation without running the function.', + }, + { + id: 'intent', + title: 'Invocation Intent', + type: 'dropdown', + condition: { field: 'operation', value: ['invoke'] }, + mode: 'advanced', + options: [ + { id: 'httprequest', label: 'HTTP request' }, + { id: 'cloudevent', label: 'CloudEvent' }, + ], + }, + { + id: 'contentType', + title: 'Content Type', + type: 'short-input', + condition: { field: 'operation', value: ['invoke'] }, + mode: 'advanced', + placeholder: 'Infer from payload', + }, + { + id: 'outputFormat', + title: 'Output Format', + type: 'dropdown', + condition: { field: 'operation', value: ['invoke'] }, + mode: 'advanced', + value: () => 'auto', + options: [ + { id: 'auto', label: 'Automatic JSON, text, or file' }, + { id: 'file', label: 'File' }, + ], + }, + { + id: 'timeoutMs', + title: 'Client Timeout (ms)', + type: 'short-input', + condition: { field: 'operation', value: ['invoke'] }, + mode: 'advanced', + description: + 'Maximum client wait, 1–300000 ms. Cancellation stops waiting and does not stop a function already running in Oracle. Do not automatically retry invocations after a lost response.', + placeholder: '300000', + }, + ], + tools: { + access: [ + 'oci_functions_invoke', + 'oci_functions_list_applications', + 'oci_functions_get_application', + 'oci_functions_create_application', + 'oci_functions_update_application', + 'oci_functions_delete_application', + 'oci_functions_change_application_compartment', + 'oci_functions_list_functions', + 'oci_functions_get_function', + 'oci_functions_create_function', + 'oci_functions_update_function', + 'oci_functions_delete_function', + ], + config: { + tool: (params) => `oci_functions_${operation(params.operation)}`, + params: (params) => { + const selected = operation(params.operation) + /** The executor merges this patch over raw inputs: explicitly unset stale semantic fields. */ + const result: Record = Object.fromEntries( + Object.values(OPERATION_PARAMS) + .flat() + .map((key) => [key, undefined]) + ) + result.oauthCredential = params.oauthCredential + result.region = + params.ociRegion === 'credential' || params.ociRegion === '' || params.ociRegion === null + ? undefined + : params.ociRegion + for (const key of OPERATION_PARAMS[selected]) { + result[key] = + (params[key] === '' || params[key] === null) && key !== 'payload' + ? undefined + : params[key] + } + if ( + 'configuration' in result && + OPERATION_PARAMS[selected].some((key) => key === 'configuration') + ) { + result.configuration = parseJson(result.configuration, 'Configuration') + } + if (selected === 'create_application') + result.subnetIds = parseJson(params.subnetIds, 'Subnet OCIDs') + if (selected === 'create_function' || selected === 'update_function') { + result.memoryInMBs = parseOptionalNumberInput(params.memoryInMBs, 'Memory', { + integer: true, + min: 128, + max: 3072, + }) + } + if (selected === 'list_applications' || selected === 'list_functions') { + result.limit = parseOptionalNumberInput(params.limit, 'Page size', { + integer: true, + min: 1, + max: 50, + }) + } + if (selected === 'invoke') { + result.payloadType = params.payloadType || 'json' + result.invocationType = params.invocationType || 'sync' + result.outputFormat = params.outputFormat || 'auto' + result.dryRun = optionalBoolean(params.dryRun) + result.timeoutMs = parseOptionalNumberInput(params.timeoutMs, 'Client timeout', { + integer: true, + min: 1, + max: 300_000, + }) + if (result.payloadType === 'file') { + result.payload = undefined + result.file = normalizeFileInput(params.file, { single: true }) + } else { + result.file = undefined + result.payload = + result.payloadType === 'json' ? parseJson(params.payload, 'Payload') : params.payload + } + } + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'OCI Functions operation' }, + oauthCredential: { type: 'string', description: 'OCI service-account credential ID' }, + ociRegion: { + type: 'string', + description: 'OCI region or credential for the configured region', + }, + applicationCompartmentId: { + type: 'string', + description: 'Compartment used only for application discovery', + }, + functionId: { type: 'string', description: 'functionId' }, + invocationType: { type: 'string', description: 'invocationType' }, + dryRun: { type: 'boolean', description: 'dryRun' }, + intent: { type: 'string', description: 'intent' }, + payloadType: { type: 'string', description: 'payloadType' }, + payload: { type: 'json', description: 'payload' }, + file: { type: 'json', description: 'file' }, + contentType: { type: 'string', description: 'contentType' }, + outputFormat: { type: 'string', description: 'outputFormat' }, + timeoutMs: { type: 'number', description: 'timeoutMs' }, + compartmentId: { type: 'string', description: 'compartmentId' }, + limit: { type: 'number', description: 'limit' }, + page: { type: 'string', description: 'page' }, + displayName: { type: 'string', description: 'displayName' }, + id: { type: 'string', description: 'id' }, + lifecycleState: { type: 'string', description: 'lifecycleState' }, + sortBy: { type: 'string', description: 'sortBy' }, + sortOrder: { type: 'string', description: 'sortOrder' }, + applicationId: { type: 'string', description: 'applicationId' }, + subnetIds: { type: 'array', description: 'subnetIds' }, + shape: { type: 'string', description: 'shape' }, + configuration: { type: 'json', description: 'configuration' }, + ifMatch: { type: 'string', description: 'ifMatch' }, + image: { type: 'string', description: 'image' }, + memoryInMBs: { type: 'number', description: 'memoryInMBs' }, + }, + outputs: { + status: { type: 'number', description: 'Oracle HTTP status' }, + opcRequestId: { type: 'string', description: 'Oracle request identifier' }, + etag: { type: 'string', description: 'Resource ETag' }, + nextPage: { type: 'string', description: 'Next page token, when more results exist' }, + application: { + type: 'json', + description: + 'Application OCID, name, lifecycle state, config, subnets, network security groups, tags, logging, tracing, and image policy', + }, + applications: { type: 'array', description: 'One page of application summaries' }, + function: { + type: 'json', + description: + 'Function OCID, name, lifecycle state, image and digest, memory, config, timeouts, concurrency, destinations, tags, and tracing', + }, + functions: { type: 'array', description: 'One page of function summaries' }, + applicationId: { type: 'string', description: 'Affected application OCID' }, + functionId: { type: 'string', description: 'Affected function OCID' }, + compartmentId: { type: 'string', description: 'Destination compartment OCID' }, + invocationType: { type: 'string', description: 'Requested invocation mode' }, + dryRun: { type: 'boolean', description: 'Whether invocation was a dry run' }, + accepted: { + type: 'boolean', + description: 'HTTP 202 acknowledgement; processing is not complete', + }, + contentType: { type: 'string', description: 'Result content type' }, + result: { type: 'json', description: 'Synchronous JSON or text result, when returned' }, + file: { type: 'file', description: 'Binary or explicitly requested result file' }, + }, +} + +export const OciFunctionsBlockMeta = { + tags: ['cloud', 'automation'], + url: 'https://www.oracle.com/cloud/cloud-native/functions/', + templates: [ + { + icon: OracleIcon, + title: 'Invoke business logic', + prompt: + 'When a webhook supplies an order, invoke an existing OCI function with the order JSON and use its synchronous result in the workflow.', + modules: ['workflows'], + category: 'operations', + tags: ['cloud'], + }, + { + icon: OracleIcon, + title: 'Start detached processing', + prompt: + 'When a scheduled batch is ready, invoke an existing OCI function in detached mode and record the acceptance status and request ID. Use its configured Oracle destination for completion results.', + modules: ['scheduled', 'workflows'], + category: 'operations', + tags: ['cloud'], + }, + { + icon: OracleIcon, + title: 'Inventory function settings', + prompt: + 'On a scheduled review, list applications in a compartment and request function pages as needed. Summarize image versions, memory, timeouts, and configuration for the selected functions.', + modules: ['scheduled', 'agent'], + category: 'engineering', + tags: ['cloud'], + }, + { + icon: OracleIcon, + title: 'Update environment settings', + prompt: + 'When an approved environment setting changes, read the application and its ETag, construct the full intended config map, and update it conditionally. Report the returned configuration.', + modules: ['workflows'], + category: 'engineering', + tags: ['cloud'], + }, + { + icon: OracleIcon, + title: 'Promote an existing image', + prompt: + 'After an image is published by your existing build process, update a selected function to that image reference using its current ETag. Return the resulting image digest and lifecycle state.', + modules: ['workflows'], + category: 'engineering', + tags: ['cloud'], + }, + { + icon: OracleIcon, + title: 'Configure warm capacity', + prompt: + 'Before a planned traffic window, inspect a function and update its provisioned concurrency with a supported memory-dependent count. Report the resulting configuration.', + modules: ['scheduled', 'workflows'], + category: 'operations', + tags: ['cloud'], + }, + { + icon: OracleIcon, + title: 'Enable function tracing', + prompt: + 'During incident investigation, read application and function tracing settings, then apply the approved tracing change using an ETag. Report the updated settings for the operator.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['cloud'], + }, + ], + skills: [ + { + name: 'invoke-oci-business-functions', + description: 'Run existing OCI business logic with a bounded synchronous payload.', + content: + '# Invoke OCI Business Functions\n\n## Steps\n\n1. Identify the existing function OCID and OCI credential.\n2. Invoke synchronously with JSON, text, or one authorized uploaded file. Keep payloads below 6 MB.\n3. Return the result or file. If the response is lost, report uncertainty; do not automatically invoke again.\n\n## Output\n\nReturn the function OCID, Oracle request ID, and result.\n\nSource: https://docs.oracle.com/en-us/iaas/Content/Functions/Tasks/functionsdownloadingsamples_topic-Sample_functions.htm', + }, + { + name: 'inspect-oci-function-configuration', + description: 'Inspect application and function settings before changing a workload.', + content: + '# Inspect OCI Function Configuration\n\n## Steps\n\n1. List one application page in the requested compartment.\n2. List functions for a selected application, following page tokens only as requested.\n3. Get the selected application and functions to inspect configuration, images, timeouts, and tags.\n\n## Output\n\nReport requested settings and whether another inventory page remains.\n\nSource: https://docs.oracle.com/en-us/iaas/Content/Functions/Tasks/functionscustomizing.htm', + }, + { + name: 'update-oci-function-images', + description: 'Promote an already published container image into an OCI function.', + content: + '# Update OCI Function Images\n\n## Steps\n\n1. Get the target function and retain its ETag and existing image reference.\n2. Use Update Function with the approved existing image reference and If Match.\n3. Read the resulting metadata. Container building and registry upload must already be handled by the existing release process.\n\n## Output\n\nReport the image, digest, lifecycle state, and request ID.\n\nSource: https://docs.oracle.com/en-us/iaas/Content/Functions/Tasks/functionsupdatingfunctions.htm', + }, + { + name: 'start-oci-detached-jobs', + description: 'Start long-running work without presenting acceptance as completion.', + content: + '# Start OCI Detached Jobs\n\n## Steps\n\n1. Get the function and inspect its detached timeout and success/failure destinations.\n2. Update the approved settings if needed, preserving omitted fields and using If Match.\n3. Invoke in detached mode once. Do not retry a lost response automatically.\n\n## Output\n\nReport acceptance and the Oracle request ID. Completion results go to configured Oracle destinations; this integration does not poll invocation results.\n\nSource: https://docs.oracle.com/en-us/iaas/Content/Functions/Tasks/functionsinvokingfunctions.htm', + }, + { + name: 'tune-oci-provisioned-concurrency', + description: 'Configure provisioned capacity for predictable cold-start behavior.', + content: + '# Tune OCI Provisioned Concurrency\n\n## Steps\n\n1. Get the function memory, ETag, and current concurrency settings.\n2. Choose an approved CONSTANT count: a multiple of 40 at 128 MB, 20 at 256 MB, or 10 at 512 MB and above.\n3. Update Function with the provisioned concurrency configuration and If Match. Use strategy NONE to remove provisioned capacity when requested.\n\n## Output\n\nReport the returned strategy and count.\n\nSource: https://docs.oracle.com/en-us/iaas/Content/Functions/Tasks/functionsusingprovisionedconcurrency.htm', + }, + { + name: 'maintain-oci-environment-variables', + description: 'Replace configuration maps without accidentally dropping existing keys.', + content: + '# Maintain OCI Environment Variables\n\n## Steps\n\n1. Get the application or function configuration and its ETag.\n2. Build the full desired map: supplied config replaces the existing map. Use an empty object only to intentionally clear it.\n3. Update the selected resource with config and If Match. Keep effective application plus function configuration within 4096 UTF-8 bytes.\n\n## Output\n\nReport the changed key names and update status.\n\nSource: https://docs.oracle.com/en-us/iaas/Content/Functions/Tasks/functionspassingconfigparams-about.htm', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index a8ff079286f..12972c10868 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -251,6 +251,7 @@ import { NotionV2BlockMeta, } from '@/blocks/blocks/notion' import { ObsidianBlock, ObsidianBlockMeta } from '@/blocks/blocks/obsidian' +import { OciFunctionsBlock, OciFunctionsBlockMeta } from '@/blocks/blocks/oci_functions' import { OktaBlock, OktaBlockMeta } from '@/blocks/blocks/okta' import { OneDriveBlock, OneDriveBlockMeta } from '@/blocks/blocks/onedrive' import { OnePasswordBlock, OnePasswordBlockMeta } from '@/blocks/blocks/onepassword' @@ -607,6 +608,7 @@ export const BLOCK_REGISTRY: Record = { notion: NotionBlock, notion_v2: NotionV2Block, obsidian: ObsidianBlock, + oci_functions: OciFunctionsBlock, okta: OktaBlock, onedrive: OneDriveBlock, onepassword: OnePasswordBlock, @@ -942,6 +944,7 @@ export const BLOCK_META_REGISTRY: Record = { notion: NotionBlockMeta, notion_v2: NotionV2BlockMeta, obsidian: ObsidianBlockMeta, + oci_functions: OciFunctionsBlockMeta, okta: OktaBlockMeta, onedrive: OneDriveBlockMeta, onepassword: OnePasswordBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index c186ddf787a..edb16628481 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9500,7 +9500,7 @@ export function NewRelicIcon(props: SVGProps) { ) } -export function NetSuiteIcon(props: SVGProps) { +export function OracleIcon(props: SVGProps) { return ( ) { ) } +export function NetSuiteIcon(props: SVGProps) { + return +} + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 54953fc2ebb..644dc7285c3 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -172,6 +172,11 @@ export const createCredentialFieldsSchema = z.object({ privateKey: z.string().trim().min(1).max(8192).optional(), /** Run-as username for key-based grants (Salesforce JWT `sub`). */ username: z.string().trim().min(1).max(255).optional(), + tenancyOcid: z.string().trim().min(1).max(255).optional(), + userOcid: z.string().trim().min(1).max(255).optional(), + fingerprint: z.string().trim().min(1).max(128).optional(), + privateKeyPassphrase: z.string().max(4096).optional(), + region: z.string().trim().min(1).max(128).optional(), }) export function refineCredentialCreate( @@ -283,6 +288,11 @@ export const updateCredentialByIdBodySchema = z authMethod: z.string().trim().min(1).max(64).optional(), privateKey: z.string().trim().min(1).max(8192).optional(), username: z.string().trim().min(1).max(255).optional(), + tenancyOcid: z.string().trim().min(1).max(255).optional(), + userOcid: z.string().trim().min(1).max(255).optional(), + fingerprint: z.string().trim().min(1).max(128).optional(), + privateKeyPassphrase: z.string().max(4096).optional(), + region: z.string().trim().min(1).max(128).optional(), }) .strict() .refine( @@ -303,7 +313,12 @@ export const updateCredentialByIdBodySchema = z data.dataCenter !== undefined || data.authMethod !== undefined || data.privateKey !== undefined || - data.username !== undefined, + data.username !== undefined || + data.tenancyOcid !== undefined || + data.userOcid !== undefined || + data.fingerprint !== undefined || + data.privateKeyPassphrase !== undefined || + data.region !== undefined, { message: 'At least one field must be provided', path: ['displayName'], diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index cc583dc8d5a..c7fd80b5e93 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -473,6 +473,16 @@ const v2ServiceAccountCredentialFieldsSchema = z .describe('Write-only PEM private key.') .meta({ writeOnly: true }), username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + tenancyOcid: z.string().trim().min(1).max(255).optional().describe('OCI tenancy OCID.'), + userOcid: z.string().trim().min(1).max(255).optional().describe('OCI user OCID.'), + fingerprint: z.string().trim().min(1).max(128).optional().describe('OCI API-key fingerprint.'), + privateKeyPassphrase: z + .string() + .max(4096) + .optional() + .describe('Write-only OCI private-key passphrase.') + .meta({ writeOnly: true }), + region: z.string().trim().min(1).max(128).optional().describe('OCI home region.'), }) .strict() @@ -722,6 +732,16 @@ const v2ServiceAccountSecretFieldsShape = { .describe('Write-only PEM private key.') .meta({ writeOnly: true }), username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), + tenancyOcid: z.string().trim().min(1).max(255).optional().describe('OCI tenancy OCID.'), + userOcid: z.string().trim().min(1).max(255).optional().describe('OCI user OCID.'), + fingerprint: z.string().trim().min(1).max(128).optional().describe('OCI API-key fingerprint.'), + privateKeyPassphrase: z + .string() + .max(4096) + .optional() + .describe('Write-only OCI private-key passphrase.') + .meta({ writeOnly: true }), + region: z.string().trim().min(1).max(128).optional().describe('OCI home region.'), } as const export const v2UpdateCredentialBodySchema = z diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index bc04f1e9282..a60dda02223 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -191,6 +191,46 @@ describe('listCredentialProviderCatalog', () => { expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce', 'trello']) }) ) }) + + it('publishes the OCI setup contract but keeps it unavailable without product metadata', async () => { + mocks.getAllOAuthServices.mockReturnValue([ + { + serviceId: 'oci', + providerId: 'oci-api-key-service-account', + serviceAccountProviderId: 'oci-api-key-service-account', + name: 'Oracle Cloud Infrastructure', + description: 'Connect to Oracle Cloud Infrastructure services.', + baseProvider: 'oci', + authType: 'service_account', + }, + ]) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: vi.fn(), + isCredentialVisible: vi.fn().mockReturnValue(false), + }) + + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + expect.objectContaining({ + type: 'service_account', + serviceId: 'oci-api-key-service-account', + providerId: 'oci-api-key-service-account', + name: 'OCI API key', + providerFamily: 'oci', + available: false, + requiresClientGeneratedCredentialId: false, + fields: [ + expect.objectContaining({ id: 'tenancyOcid', required: true, secret: false }), + expect.objectContaining({ id: 'userOcid', required: true, secret: false }), + expect.objectContaining({ id: 'fingerprint', required: true, secret: false }), + expect.objectContaining({ id: 'privateKey', required: true, secret: true }), + expect.objectContaining({ id: 'privateKeyPassphrase', required: false, secret: true }), + expect.objectContaining({ id: 'region', required: true, secret: false }), + ], + }), + ]) + }) }) describe('requireAvailableServiceAccountCredentialProvider', () => { diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index a1085d2fb4d..132d1363273 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -20,6 +20,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthServiceMetadata, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' @@ -213,6 +214,63 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript ], } } + if (providerId === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'OCI API key', + description: 'Connect Oracle Cloud Infrastructure with an API signing key.', + docsUrl: 'https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm', + fields: [ + { + id: 'tenancyOcid', + label: 'Tenancy OCID', + placeholder: 'ocid1.tenancy.oc1..', + required: true, + secret: false, + multiline: false, + }, + { + id: 'userOcid', + label: 'User OCID', + placeholder: 'ocid1.user.oc1..', + required: true, + secret: false, + multiline: false, + }, + { + id: 'fingerprint', + label: 'Fingerprint', + placeholder: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + required: true, + secret: false, + multiline: false, + }, + { + id: 'privateKey', + label: 'Private key', + placeholder: '-----BEGIN PRIVATE KEY-----', + required: true, + secret: true, + multiline: true, + }, + { + id: 'privateKeyPassphrase', + label: 'Private-key passphrase', + placeholder: 'Optional', + required: false, + secret: true, + multiline: false, + }, + { + id: 'region', + label: 'Region', + placeholder: 'us-ashburn-1', + required: true, + secret: false, + multiline: false, + }, + ], + } + } const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts new file mode 100644 index 00000000000..930abe6b634 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.test.ts @@ -0,0 +1,268 @@ +/** + * @vitest-environment node + */ +import { createHash, createPublicKey, generateKeyPairSync, type KeyObject } from 'node:crypto' +import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const hoisted = vi.hoisted(() => ({ + verifySetup: vi.fn(), +})) + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/internal/oci/client.server', () => ({ + verifyOciApiKeyCredentialForSetup: hoisted.verifySetup, +})) + +import { + OciCredentialVerificationError, + verifyAndEncryptOciApiKeyCredential, +} from '@/lib/credentials/oci-api-key-service-account.server' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +const dependencies = { + ...hoisted, + encryptSecret: encryptionMockFns.mockEncryptSecret, +} + +const TENANCY_OCID = 'ocid1.tenancy.oc1..aaaaaaaafoundationtenant' +const USER_OCID = 'ocid1.user.oc1..aaaaaaaafoundationuser' + +function fingerprintForKey(privateKey: KeyObject): string { + const der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) + const bytes = createHash('md5').update(der).digest('hex').match(/.{2}/g) + if (!bytes) throw new Error('Expected a key fingerprint') + return bytes.join(':') +} + +describe('OCI API-key credential setup', () => { + let privateKeyObject: KeyObject + let privateKey: string + let fingerprint: string + let encryptedPrivateKey: string + const passphrase = ' exact passphrase ' + + beforeAll(() => { + privateKeyObject = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey + privateKey = privateKeyObject.export({ format: 'pem', type: 'pkcs8' }).toString() + fingerprint = fingerprintForKey(privateKeyObject) + encryptedPrivateKey = privateKeyObject + .export({ + format: 'pem', + type: 'pkcs8', + cipher: 'aes-256-cbc', + passphrase, + }) + .toString() + }) + + beforeEach(() => { + dependencies.verifySetup.mockResolvedValue(new TextEncoder().encode('"namespace"')) + dependencies.encryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + function fields(overrides: Record = {}) { + return { + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, + fingerprint, + privateKey, + region: 'us-ashburn-1', + ...overrides, + } + } + + it('normalizes stable external fields and encrypts only after GetNamespace succeeds', async () => { + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ + tenancyOcid: ` ${TENANCY_OCID} `, + userOcid: ` ${USER_OCID} `, + fingerprint: fingerprint.toUpperCase().replaceAll(':', ' '), + privateKey: privateKey.replaceAll('\n', '\r\n'), + region: ' US-ASHBURN-1 ', + }) + ) + ).resolves.toEqual({ encryptedServiceAccountKey: 'ciphertext', userOcid: USER_OCID }) + + const serialized = dependencies.verifySetup.mock.calls[0][0] + const secret = JSON.parse(serialized) + expect(secret).toEqual({ + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyOcid: TENANCY_OCID, + userOcid: USER_OCID, + fingerprint, + privateKey, + region: 'us-ashburn-1', + metadata: { principalKind: 'user', principalId: USER_OCID }, + }) + expect(dependencies.encryptSecret).toHaveBeenCalledWith(serialized) + expect(dependencies.verifySetup.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.encryptSecret.mock.invocationCallOrder[0] + ) + }) + + it.each(['\n', '\r\n'])( + 'accepts the exact Oracle download marker with %j line endings', + async (newline) => { + for (const [pem, keyPassphrase] of [ + [privateKey, undefined], + [encryptedPrivateKey, passphrase], + ]) { + await verifyAndEncryptOciApiKeyCredential( + fields({ + privateKey: `${pem}OCI_API_KEY\n`.replaceAll('\n', newline), + privateKeyPassphrase: keyPassphrase, + }) + ) + const call = dependencies.verifySetup.mock.lastCall + if (!call) throw new Error('Expected OCI setup verification') + const serialized = call[0] + expect(JSON.parse(serialized).privateKey).toBe(pem) + expect(dependencies.encryptSecret).toHaveBeenLastCalledWith(serialized) + } + } + ) + + it('rejects malformed download trailers before provider verification', async () => { + for (const suffix of [ + 'OCI_API_KEY_EXTRA', + 'oci_api_key', + 'OCI_API_KEY\nextra', + 'extra\nOCI_API_KEY', + 'OCI_API_KEY\nOCI_API_KEY', + '\u0000OCI_API_KEY', + privateKey, + `extra\n-----END PRIVATE KEY-----`, + ]) { + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ privateKey: `${privateKey}${suffix}` })) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + } + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: `${privateKey}${' '.repeat(65536)}\nOCI_API_KEY` }) + ) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ + privateKey: `${privateKey}OCI_API_KEY`, + fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00', + }) + ) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('accepts encrypted RSA keys only with the exact preserved passphrase', async () => { + await verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase }) + ) + expect(JSON.parse(dependencies.verifySetup.mock.calls[0][0]).privateKeyPassphrase).toBe( + passphrase + ) + + await expect( + verifyAndEncryptOciApiKeyCredential(fields({ privateKey: encryptedPrivateKey })) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + await expect( + verifyAndEncryptOciApiKeyCredential( + fields({ privateKey: encryptedPrivateKey, privateKeyPassphrase: passphrase.trim() }) + ) + ).rejects.toEqual(new OciCredentialVerificationError('invalid_credentials')) + }) + + it('rejects malformed, non-RSA, and undersized keys before network or encryption', async () => { + const ecKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey + const smallKey = generateKeyPairSync('rsa', { modulusLength: 1024 }).privateKey + const cases = [ + fields({ privateKey: 'not a key' }), + fields({ + privateKey: ecKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(ecKey), + }), + fields({ + privateKey: smallKey.export({ format: 'pem', type: 'pkcs8' }).toString(), + fingerprint: fingerprintForKey(smallKey), + }), + ] + for (const invalid of cases) { + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') + ) + } + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('validates fingerprint, OCID types and realms, regions, controls, and size limits locally', async () => { + const invalidCases = [ + fields({ fingerprint: '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' }), + fields({ tenancyOcid: USER_OCID }), + fields({ userOcid: 'ocid1.user.oc2..aaaaaaaafoundationuser' }), + fields({ region: 'us-gov-ashburn-1' }), + fields({ region: 'moon-base-1' }), + fields({ userOcid: `${USER_OCID}\n` }), + fields({ privateKey: `${privateKey}\u0000` }), + fields({ privateKeyPassphrase: 'x'.repeat(4097) }), + fields({ tenancyOcid: `ocid1.tenancy.oc1..${'a'.repeat(240)}` }), + ] + for (const invalid of invalidCases) { + await expect(verifyAndEncryptOciApiKeyCredential(invalid)).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') + ) + } + expect(dependencies.verifySetup).not.toHaveBeenCalled() + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('maps authentication, malformed-response, and transient failures without leaking details', async () => { + dependencies.verifySetup.mockRejectedValueOnce( + new OciClientError('request_failed', { status: 401 }) + ) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_credentials') + ) + + dependencies.verifySetup.mockResolvedValueOnce(new TextEncoder().encode('{"secret":"echo"}')) + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toEqual( + new OciCredentialVerificationError('invalid_response') + ) + + dependencies.verifySetup.mockRejectedValueOnce(new Error('provider echoed a secret')) + const failure = await verifyAndEncryptOciApiKeyCredential(fields()).catch( + (error: unknown) => error + ) + expect(failure).toEqual(new OciCredentialVerificationError('service_unavailable')) + expect((failure as Error).message).not.toContain('provider') + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) + + it('preserves an encryption failure after successful provider verification', async () => { + const encryptionFailure = new Error('internal encryption failure') + dependencies.encryptSecret.mockRejectedValueOnce(encryptionFailure) + + await expect(verifyAndEncryptOciApiKeyCredential(fields())).rejects.toBe(encryptionFailure) + expect(dependencies.verifySetup).toHaveBeenCalledOnce() + }) + + it('forwards cancellation and never encrypts an aborted verification', async () => { + const controller = new AbortController() + const reason = new DOMException('canceled', 'AbortError') + dependencies.verifySetup.mockImplementationOnce(async (_secret, signal: AbortSignal) => { + controller.abort(reason) + throw signal.reason + }) + await expect(verifyAndEncryptOciApiKeyCredential(fields(), controller.signal)).rejects.toBe( + reason + ) + expect(dependencies.encryptSecret).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/oci-api-key-service-account.server.ts b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts new file mode 100644 index 00000000000..46b92ceb0c7 --- /dev/null +++ b/apps/sim/lib/credentials/oci-api-key-service-account.server.ts @@ -0,0 +1,211 @@ +import { createHash, createPrivateKey, createPublicKey } from 'node:crypto' +import { safeCompare } from '@sim/security/compare' +import { encryptSecret } from '@/lib/core/security/encryption' +import { serviceAccountPrincipalMetadata } from '@/lib/credentials/principal' +import { verifyOciApiKeyCredentialForSetup } from '@/lib/internal/oci/client.server' +import { getOciRegion } from '@/lib/internal/oci/endpoints' +import { OciClientError } from '@/lib/internal/oci/errors' +import { + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +const MAX_OCID_LENGTH = 255 +const MAX_PRIVATE_KEY_BYTES = 64 * 1024 +const MAX_PASSPHRASE_BYTES = 4 * 1024 +const OCID_PATTERN = /^ocid1\.([a-z][a-z0-9_-]*)\.([a-z0-9]+)\.([a-z0-9-]*)\.([a-zA-Z0-9_-]+)$/ +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/ +const PEM_CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/ + +export interface OciApiKeyCredentialFields { + tenancyOcid: string + userOcid: string + fingerprint: string + privateKey: string + privateKeyPassphrase?: string + region: string +} + +interface OciApiKeyServiceAccountSecret { + readonly type: typeof OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE + readonly providerId: typeof OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID + readonly tenancyOcid: string + readonly userOcid: string + readonly fingerprint: string + readonly privateKey: string + readonly privateKeyPassphrase?: string + readonly region: string + readonly metadata: { + readonly principalKind: 'user' + readonly principalId: string + } +} + +export type OciCredentialVerificationCode = + | 'invalid_credentials' + | 'invalid_response' + | 'service_unavailable' + +/** Safe error categories for credential verification callers. */ +export class OciCredentialVerificationError extends Error { + constructor(public readonly code: OciCredentialVerificationCode) { + super(code) + this.name = 'OciCredentialVerificationError' + } +} + +function assertBoundedText( + value: unknown, + field: string, + maxBytes: number, + controlPattern = CONTROL_CHARACTER_PATTERN +): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + controlPattern.test(value) + ) { + throw new Error(`OCI ${field} is invalid`) + } +} + +function normalizeOcid( + value: unknown, + expectedType: 'tenancy' | 'user' +): { value: string; realmId: string } { + assertBoundedText(value, `${expectedType} OCID`, MAX_OCID_LENGTH) + const normalized = value.trim() + const match = OCID_PATTERN.exec(normalized) + if (!match || match[1] !== expectedType) { + throw new Error(`OCI ${expectedType} OCID has the wrong structure or resource type`) + } + return { value: normalized, realmId: match[2] } +} + +function normalizeFingerprint(value: unknown): string { + assertBoundedText(value, 'fingerprint', 128) + const hex = value.replace(/[:\s]/g, '').toLowerCase() + const bytes = /^[0-9a-f]{32}$/.test(hex) ? hex.match(/.{2}/g) : null + if (!bytes) throw new Error('OCI fingerprint must contain 16 MD5 bytes') + return bytes.join(':') +} + +function normalizePrivateKey(value: unknown): string { + assertBoundedText(value, 'private key', MAX_PRIVATE_KEY_BYTES, PEM_CONTROL_CHARACTER_PATTERN) + const normalized = value.replace(/\r\n?/g, '\n').trim() + const pem = normalized.endsWith('\nOCI_API_KEY') + ? normalized.slice(0, -'\nOCI_API_KEY'.length).trimEnd() + : normalized + if ( + !/^-----BEGIN (PRIVATE KEY|RSA PRIVATE KEY|ENCRYPTED PRIVATE KEY)-----\n(?:(?!-----BEGIN |-----END )[\s\S])+\n-----END \1-----$/.test( + pem + ) + ) { + throw new Error('OCI private key must be PEM encoded') + } + return `${pem}\n` +} + +function validatePassphrase(value: unknown): string | undefined { + if (value === undefined) return undefined + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > MAX_PASSPHRASE_BYTES || + CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw new Error('OCI private-key passphrase is invalid') + } + return value +} + +function buildSecret(fields: OciApiKeyCredentialFields): OciApiKeyServiceAccountSecret { + const tenancy = normalizeOcid(fields.tenancyOcid, 'tenancy') + const user = normalizeOcid(fields.userOcid, 'user') + if (tenancy.realmId !== user.realmId) { + throw new Error('OCI tenancy and user OCIDs must share a realm') + } + assertBoundedText(fields.region, 'region', 128) + const region = getOciRegion(fields.region) + if (region.realm.id !== tenancy.realmId) { + throw new Error('OCI region must belong to the credential realm') + } + const fingerprint = normalizeFingerprint(fields.fingerprint) + const privateKey = normalizePrivateKey(fields.privateKey) + const privateKeyPassphrase = validatePassphrase(fields.privateKeyPassphrase) + + let key + try { + key = createPrivateKey({ + key: privateKey, + format: 'pem', + ...(privateKeyPassphrase !== undefined ? { passphrase: privateKeyPassphrase } : {}), + }) + } catch { + throw new Error('OCI private key or passphrase is invalid') + } + if (key.asymmetricKeyType !== 'rsa') throw new Error('OCI private key must use RSA') + if ( + key.asymmetricKeyDetails?.modulusLength === undefined || + key.asymmetricKeyDetails.modulusLength < 2048 + ) { + throw new Error('OCI RSA private key must be at least 2048 bits') + } + const spki = createPublicKey(key).export({ format: 'der', type: 'spki' }) + const derived = createHash('md5').update(spki).digest().toString('base64') + const submitted = Buffer.from(fingerprint.replaceAll(':', ''), 'hex').toString('base64') + if (!safeCompare(derived, submitted)) { + throw new Error('OCI fingerprint does not match the private key') + } + + const metadata = serviceAccountPrincipalMetadata({ kind: 'user', id: user.value }) + return { + type: OCI_API_KEY_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + tenancyOcid: tenancy.value, + userOcid: user.value, + fingerprint, + privateKey, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region: region.id, + metadata: { principalKind: 'user', principalId: metadata.principalId }, + } +} + +/** Validates, verifies with GetNamespace, and only then encrypts an OCI credential. */ +export async function verifyAndEncryptOciApiKeyCredential( + fields: OciApiKeyCredentialFields, + signal?: AbortSignal +): Promise<{ encryptedServiceAccountKey: string; userOcid: string }> { + let secret: OciApiKeyServiceAccountSecret + try { + secret = buildSecret(fields) + } catch { + throw new OciCredentialVerificationError('invalid_credentials') + } + let responseBody: Uint8Array + try { + responseBody = await verifyOciApiKeyCredentialForSetup(JSON.stringify(secret), signal) + } catch (error) { + if (signal?.aborted) throw error + if (error instanceof OciClientError && (error.status === 401 || error.status === 403)) { + throw new OciCredentialVerificationError('invalid_credentials') + } + throw new OciCredentialVerificationError('service_unavailable') + } + try { + const namespace: unknown = JSON.parse(Buffer.from(responseBody).toString('utf8')) + if ( + typeof namespace !== 'string' || + namespace.length === 0 || + Buffer.byteLength(namespace, 'utf8') > 255 || + CONTROL_CHARACTER_PATTERN.test(namespace) + ) { + throw new Error('invalid namespace') + } + } catch { + throw new OciCredentialVerificationError('invalid_response') + } + const { encrypted } = await encryptSecret(JSON.stringify(secret)) + return { encryptedServiceAccountKey: encrypted, userOcid: secret.userOcid } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index dbbf980b3dc..e40c30542c8 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -19,6 +19,7 @@ import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext, requireOrdinaryCredentialType } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' +import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' import type { CredentialOrchestrationErrorCode } from '@/lib/credentials/orchestration' import { getCredentialCreationOrganizationContext } from '@/lib/credentials/organization' import type { AtlassianProduct } from '@/lib/credentials/service-account-fields' @@ -87,6 +88,11 @@ export interface PerformCreateCredentialParams { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string /** * Client-supplied credential id, honored only for `slack-custom-bot`: the * setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` before @@ -308,6 +314,11 @@ export async function createCredentialRecord( authMethod: params.authMethod, privateKey: params.privateKey, username: params.username, + tenancyOcid: params.tenancyOcid, + userOcid: params.userOcid, + fingerprint: params.fingerprint, + privateKeyPassphrase: params.privateKeyPassphrase, + region: params.region, }) resolvedProviderId = secret.providerId resolvedAccountId = null @@ -553,6 +564,18 @@ export async function createCredentialRecord( return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { + if (error instanceof OciCredentialVerificationError) { + const providerUnavailable = error.code !== 'invalid_credentials' + const providerErrorCode = providerUnavailable ? 'provider_unavailable' : 'invalid_credentials' + logger.warn(`OCI credential rejected: ${error.code}`) + return failure( + providerUnavailable + ? 'OCI is temporarily unavailable for credential verification' + : 'OCI API-key credential could not be verified', + 'validation', + { providerErrorCode, providerUnavailable } + ) + } if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { code: error.code, diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index e0b0008e450..f972a232ce7 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -64,6 +64,13 @@ vi.mock('@/lib/credentials/environment', () => credentialsEnvironmentMock) vi.mock('@/lib/credentials/atlassian-service-account', () => ({ AtlassianValidationError: class AtlassianValidationError extends Error {}, })) +vi.mock('@/lib/credentials/oci-api-key-service-account.server', () => ({ + OciCredentialVerificationError: class OciCredentialVerificationError extends Error { + constructor(readonly code: string) { + super(code) + } + }, +})) vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ TokenServiceAccountValidationError: class TokenServiceAccountValidationError extends Error {}, })) @@ -164,6 +171,111 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(result.previousDisplayName).toBe(OLD_EMAIL) }) + it('requires the complete OCI tuple and treats an omitted passphrase as clearing it', async () => { + mockCredential({ + providerId: 'oci-api-key-service-account', + displayName: 'OCI production signer', + }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'new-oci-cipher', + displayName: 'ocid1.user.oc1..replacement', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..replacement', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..replacement' }, + }) + + const { ServiceAccountSecretError } = await import('@/lib/credentials/service-account-secret') + mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce( + new ServiceAccountSecretError( + 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials' + ) + ) + + const incomplete = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + privateKey: 'replacement-key', + }) + expect(incomplete).toMatchObject({ + success: false, + errorCode: 'validation', + error: expect.stringContaining('tenancyOcid, userOcid, fingerprint, privateKey, and region'), + }) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ privateKey: 'replacement-key' }) + ) + + const complete = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + region: 'us-ashburn-1', + }) + expect(complete.success).toBe(true) + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenLastCalledWith( + 'oci-api-key-service-account', + expect.objectContaining({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + privateKeyPassphrase: undefined, + region: 'us-ashburn-1', + }) + ) + expect(updatePayload().encryptedServiceAccountKey).toBe('new-oci-cipher') + }) + + it.each([ + ['invalid_credentials', 'invalid_credentials', 'OCI API-key credential could not be verified'], + [ + 'service_unavailable', + 'provider_unavailable', + 'OCI is temporarily unavailable for credential verification', + ], + [ + 'invalid_response', + 'provider_unavailable', + 'OCI is temporarily unavailable for credential verification', + ], + ] as const)( + 'maps OCI %s rotation failures without changing generic service-account errors', + async (code, providerErrorCode, message) => { + mockCredential({ providerId: 'oci-api-key-service-account' }) + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue( + new OciCredentialVerificationError(code) + ) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..replacement', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'replacement-key', + region: 'us-ashburn-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + error: message, + providerErrorCode, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) + it('keeps a label the user typed instead of the derived identity', async () => { mockCredential({ displayName: 'Prod billing exporter' }) mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) @@ -288,6 +400,7 @@ describe('performUpdateCredential — service-account secret rotation', () => { }) expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(result).not.toHaveProperty('providerErrorCode') expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockRecordAudit).not.toHaveBeenCalled() }) @@ -370,6 +483,59 @@ describe('createServiceAccountCredential', () => { resetDbChainMock() }) + it.each([ + [ + 'invalid_credentials', + 'invalid_credentials', + false, + 'OCI API-key credential could not be verified', + ], + [ + 'service_unavailable', + 'provider_unavailable', + true, + 'OCI is temporarily unavailable for credential verification', + ], + [ + 'invalid_response', + 'provider_unavailable', + true, + 'OCI is temporarily unavailable for credential verification', + ], + ] as const)( + 'maps OCI %s creation failures through the existing provider result contract', + async (code, providerErrorCode, providerUnavailable, message) => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue( + new OciCredentialVerificationError(code) + ) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'oci-api-key-service-account', + displayName: 'OCI signer', + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + error: message, + providerErrorCode, + providerUnavailable, + }) + expect(JSON.stringify(result)).not.toContain('provider-secret-key') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + } + ) + it('rejects an existing service-account source instead of discarding the submitted secret', async () => { mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ providerId: 'zoom-service-account', diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 356a0084680..ce90ffadd2f 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -37,6 +37,7 @@ import { deletePersonalEnvCredentialForUser, deleteWorkspaceEnvCredentials, } from '@/lib/credentials/environment' +import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' import type { AtlassianProduct, ServiceAccountFieldId, @@ -90,6 +91,11 @@ const ROTATABLE_SECRET_FIELDS: readonly ServiceAccountFieldId[] = [ 'authMethod', 'privateKey', 'username', + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKeyPassphrase', + 'region', ] /** @@ -203,6 +209,11 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } export interface PerformCredentialResult { @@ -401,6 +412,11 @@ export async function updateCredentialRecord( : params.authMethod, privateKey: params.privateKey, username: needsStoredUsername ? readStoredField(storedBlob, 'username') : params.username, + tenancyOcid: params.tenancyOcid, + userOcid: params.userOcid, + fingerprint: params.fingerprint, + privateKeyPassphrase: params.privateKeyPassphrase, + region: params.region, }) updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId @@ -421,6 +437,17 @@ export async function updateCredentialRecord( if (error instanceof ServiceAccountSecretError) { return { success: false, error: error.message, errorCode: 'validation' } } + if (error instanceof OciCredentialVerificationError) { + const providerUnavailable = error.code !== 'invalid_credentials' + return { + success: false, + error: providerUnavailable + ? 'OCI is temporarily unavailable for credential verification' + : 'OCI API-key credential could not be verified', + errorCode: 'validation', + providerErrorCode: providerUnavailable ? 'provider_unavailable' : 'invalid_credentials', + } + } if (error instanceof AtlassianValidationError) { // Surface the provider code so the client maps it to the specific // token/domain message (create returns it too). diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 620b4336b2c..c90746b62d1 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -3,6 +3,7 @@ import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-s import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -25,6 +26,11 @@ export type ServiceAccountFieldId = | 'authMethod' | 'privateKey' | 'username' + | 'tenancyOcid' + | 'userOcid' + | 'fingerprint' + | 'privateKeyPassphrase' + | 'region' /** * Required create-body fields per service-account provider — the client-safe @@ -33,13 +39,20 @@ export type ServiceAccountFieldId = * providers from descriptor fields, bespoke providers inline.) Token-paste * providers contribute their entries from * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`, client-credential providers from - * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are + * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the four bespoke providers are * declared here. */ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = { [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['apiToken', 'domain'], [SLACK_CUSTOM_BOT_PROVIDER_ID]: ['signingSecret', 'botToken'], + [OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID]: [ + 'tenancyOcid', + 'userOcid', + 'fingerprint', + 'privateKey', + 'region', + ], ...TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, ...CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS, } diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index b77be5d8bd9..2d4d9ded299 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -9,6 +9,7 @@ import { import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' @@ -29,6 +30,7 @@ export function asServiceAccountProviderId( value === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID || value === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID || value === SLACK_CUSTOM_BOT_PROVIDER_ID || + value === OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID || isTokenServiceAccountProviderId(value) || isClientCredentialAccountProviderId(value) ) { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 00bc007fe81..69a740bd540 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -6,11 +6,13 @@ const { mockValidateAtlassian, mockNormalizeDomain, mockClientCredentialMinter, + mockVerifyAndEncryptOci, } = vi.hoisted(() => ({ mockFetchSlackTeamId: vi.fn(), mockValidateAtlassian: vi.fn(), mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), mockClientCredentialMinter: vi.fn(), + mockVerifyAndEncryptOci: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) @@ -20,6 +22,14 @@ vi.mock('@/lib/credentials/atlassian-service-account', () => ({ validateAtlassianServiceAccount: mockValidateAtlassian, normalizeAtlassianDomain: mockNormalizeDomain, })) +vi.mock('@/lib/credentials/oci-api-key-service-account.server', () => ({ + OciCredentialVerificationError: class OciCredentialVerificationError extends Error { + constructor(readonly code: string) { + super(code) + } + }, + verifyAndEncryptOciApiKeyCredential: mockVerifyAndEncryptOci, +})) vi.mock('@/lib/api/contracts/credentials', () => ({ serviceAccountJsonSchema: { safeParse: (value: string) => { @@ -106,6 +116,109 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe('google-service-account') }) + it('verifies and stores an OCI API-key credential with stable external fields', async () => { + mockVerifyAndEncryptOci.mockResolvedValue({ + encryptedServiceAccountKey: 'oci-ciphertext', + userOcid: 'ocid1.user.oc1..principal', + }) + + const result = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' preserved exactly ', + region: 'us-ashburn-1', + }) + + expect(mockVerifyAndEncryptOci).toHaveBeenCalledWith({ + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + privateKeyPassphrase: ' preserved exactly ', + region: 'us-ashburn-1', + }) + expect(result).toEqual({ + providerId: 'oci-api-key-service-account', + encryptedServiceAccountKey: 'oci-ciphertext', + displayName: 'ocid1.user.oc1..principal', + auditMetadata: { + principalKind: 'user', + principalId: 'ocid1.user.oc1..principal', + }, + principal: { kind: 'user', id: 'ocid1.user.oc1..principal' }, + }) + }) + + it('requires the complete OCI signing tuple before verification', async () => { + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + }) + ).rejects.toThrow('tenancyOcid, userOcid, fingerprint, privateKey, and region are required') + expect(mockVerifyAndEncryptOci).not.toHaveBeenCalled() + }) + + it.each(['service_unavailable', 'invalid_response'] as const)( + 'preserves the dedicated OCI %s classification for orchestration', + async (code) => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndEncryptOci.mockRejectedValue(new OciCredentialVerificationError(code)) + + const failure = await verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(OciCredentialVerificationError) + expect(failure).toMatchObject({ + message: code, + code, + }) + expect(JSON.stringify(failure)).not.toContain('provider-secret-key') + } + ) + + it('preserves the dedicated OCI invalid-credential classification for orchestration', async () => { + const { OciCredentialVerificationError } = await import( + '@/lib/credentials/oci-api-key-service-account.server' + ) + mockVerifyAndEncryptOci.mockRejectedValue( + new OciCredentialVerificationError('invalid_credentials') + ) + + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: 'invalid-fingerprint', + privateKey: 'invalid-key', + region: 'us-ashburn-1', + }) + ).rejects.toMatchObject({ message: 'invalid_credentials', code: 'invalid_credentials' }) + }) + + it('does not misclassify an internal OCI credential failure as rejected credentials', async () => { + const internalFailure = new Error('internal encryption failure') + mockVerifyAndEncryptOci.mockRejectedValue(internalFailure) + + await expect( + verifyAndBuildServiceAccountSecret('oci-api-key-service-account', { + tenancyOcid: 'ocid1.tenancy.oc1..tenant', + userOcid: 'ocid1.user.oc1..principal', + fingerprint: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff', + privateKey: 'provider-secret-key', + region: 'us-ashburn-1', + }) + ).rejects.toBe(internalFailure) + }) + it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => { const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) await expect( diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 4fc12acab7b..892add4874d 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -20,6 +20,7 @@ import { getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' +import { verifyAndEncryptOciApiKeyCredential } from '@/lib/credentials/oci-api-key-service-account.server' import { type ServiceAccountPrincipal, serviceAccountPrincipalMetadata, @@ -39,6 +40,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -60,6 +62,11 @@ export interface ServiceAccountSecretFields { authMethod?: string privateKey?: string username?: string + tenancyOcid?: string + userOcid?: string + fingerprint?: string + privateKeyPassphrase?: string + region?: string } export interface ServiceAccountSecretResult { @@ -222,6 +229,34 @@ async function buildGoogleServiceAccountSecret( } } +async function buildOciApiKeyServiceAccountSecret( + fields: ServiceAccountSecretFields +): Promise { + const { tenancyOcid, userOcid, fingerprint, privateKey, privateKeyPassphrase, region } = fields + if (!tenancyOcid || !userOcid || !fingerprint || !privateKey || !region) { + throw new ServiceAccountSecretError( + 'tenancyOcid, userOcid, fingerprint, privateKey, and region are required for OCI API-key credentials' + ) + } + const result = await verifyAndEncryptOciApiKeyCredential({ + tenancyOcid, + userOcid, + fingerprint, + privateKey, + ...(privateKeyPassphrase !== undefined ? { privateKeyPassphrase } : {}), + region, + }) + const principal: ServiceAccountPrincipal = { kind: 'user', id: result.userOcid } + const metadata = serviceAccountPrincipalMetadata(principal) + return { + providerId: OCI_API_KEY_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: result.encryptedServiceAccountKey, + displayName: result.userOcid, + auditMetadata: metadata, + principal, + } +} + /** * Builds a token-paste service-account secret for any provider registered in * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the @@ -355,6 +390,7 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record = { 'linear-service-account': ['linear'], 'monday-service-account': ['monday'], 'notion-service-account': ['notion'], + // OCI owns reusable credential setup but intentionally exposes no product + // integration until a native OCI product supplies visible catalog metadata. + 'oci-api-key-service-account': [], // NetSuite remains an API-key catalog integration, like Snowflake, while its // block uses the shared reusable-credential selector. 'netsuite-service-account': [], diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 34c62ec06c2..d0bb839b84d 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -176,6 +176,7 @@ import { ObsidianIcon, OktaIcon, OnePasswordIcon, + OracleIcon, OtterIcon, OutlookIcon, PackageSearchIcon, @@ -471,6 +472,7 @@ export const blockTypeToIconMap: Record = { notion: NotionIcon, notion_v2: NotionIcon, obsidian: ObsidianIcon, + oci_functions: OracleIcon, okta: OktaIcon, onedrive: MicrosoftOneDriveIcon, onepassword: OnePasswordIcon, diff --git a/apps/sim/lib/internal/oci-functions/client.test.ts b/apps/sim/lib/internal/oci-functions/client.test.ts new file mode 100644 index 00000000000..6ddb125b17b --- /dev/null +++ b/apps/sim/lib/internal/oci-functions/client.test.ts @@ -0,0 +1,123 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ create: vi.fn(), prepare: vi.fn(), request: vi.fn() })) +vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.create })) + +import type { OciAuthenticatedResponse } from '@/lib/internal/oci/client.server' +import { + OCI_FUNCTIONS_MANAGEMENT_POLICY, + ociFunctionsResponseMetadata, + prepareOciFunctionsClient, + projectOciFunctionsResource, + requestOciFunctionsManagement, +} from '@/lib/internal/oci-functions/client' + +function response(body: unknown, status = 200): OciAuthenticatedResponse { + return { + status, + headers: { etag: 'v1', 'opc-next-page': 'page-2' }, + opcRequestId: 'request-1', + body: new TextEncoder().encode(JSON.stringify(body)), + } as OciAuthenticatedResponse +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.prepare.mockResolvedValue({ origin: 'https://functions.us-ashburn-1.oci.oraclecloud.com' }) + mocks.create.mockResolvedValue({ prepareStaticEndpoint: mocks.prepare, request: mocks.request }) +}) + +describe('OCI Functions client adapter', () => { + it('binds the authorized credential, trusted workspace, fixed service, and management policy', async () => { + await prepareOciFunctionsClient({ + credentialId: 'resolved-id', + workspaceId: 'workspace-1', + region: 'us-ashburn-1', + }) + expect(mocks.create).toHaveBeenCalledWith({ + credentialId: 'resolved-id', + workspaceId: 'workspace-1', + region: 'us-ashburn-1', + serviceId: 'oci-functions', + }) + expect(mocks.prepare).toHaveBeenCalledWith(OCI_FUNCTIONS_MANAGEMENT_POLICY) + expect(OCI_FUNCTIONS_MANAGEMENT_POLICY.hostnameTemplate).toBe('regional-oci') + }) + + it('retains an omitted region for credential-default selection', async () => { + await prepareOciFunctionsClient({ credentialId: 'resolved-id', workspaceId: 'workspace-1' }) + expect(mocks.create).toHaveBeenCalledWith({ + credentialId: 'resolved-id', + workspaceId: 'workspace-1', + serviceId: 'oci-functions', + }) + }) + + it('projects documented details and strips fields not in the Oracle response contract', () => { + const result = projectOciFunctionsResource( + response({ + id: 'fn-1', + config: { ENV: 'prod' }, + invokeEndpoint: 'https://oracle.example', + privateCanary: 'hidden', + traceConfig: { isEnabled: false, privateCanary: 'hidden' }, + }), + 'functions' + ) + expect(result).toEqual({ + id: 'fn-1', + config: { ENV: 'prod' }, + invokeEndpoint: 'https://oracle.example', + traceConfig: { isEnabled: false }, + }) + expect( + projectOciFunctionsResource( + response([ + { + id: 'app-1', + config: { hidden: 'summary omits config' }, + syslogUrl: 'summary omits syslog', + }, + ]), + 'applications', + true + ) + ).toEqual([{ id: 'app-1' }]) + }) + + it('rejects malformed resource envelopes without leaking provider content', () => { + expect(() => projectOciFunctionsResource(response({ secret: 'canary' }), 'functions')).toThrow( + 'Invalid OCI Functions resource response' + ) + expect(() => + projectOciFunctionsResource(response({ items: [] }), 'applications', true) + ).toThrow('Invalid OCI Functions resource response') + }) + + it('preserves documented pagination and concurrency headers', () => { + expect(ociFunctionsResponseMetadata(response({}))).toEqual({ + status: 200, + etag: 'v1', + nextPage: 'page-2', + opcRequestId: 'request-1', + }) + }) + + it('does not parse an empty 204 response and sends no DELETE body or retry policy', async () => { + const prepared = await prepareOciFunctionsClient({ + credentialId: 'resolved-id', + workspaceId: 'workspace-1', + }) + mocks.request.mockResolvedValue({ status: 204, headers: {}, body: new Uint8Array() }) + await requestOciFunctionsManagement(prepared, { + method: 'DELETE', + path: '/20181201/functions/function-1', + ifMatch: 'v1', + }) + const request = mocks.request.mock.calls[0][0] + expect(request).toMatchObject({ method: 'DELETE', headers: { 'if-match': 'v1' } }) + expect(request).not.toHaveProperty('body') + expect(request).not.toHaveProperty('retry') + }) +}) diff --git a/apps/sim/lib/internal/oci-functions/client.ts b/apps/sim/lib/internal/oci-functions/client.ts new file mode 100644 index 00000000000..3b3383427ec --- /dev/null +++ b/apps/sim/lib/internal/oci-functions/client.ts @@ -0,0 +1,192 @@ +import { z } from 'zod' +import { + createOciClient, + type OciAuthenticatedResponse, + type OciClient, +} from '@/lib/internal/oci/client.server' +import { + createOciDiscoveredEndpointPolicy, + createOciStaticEndpointPolicy, + type OciPreparedEndpoint, +} from '@/lib/internal/oci/endpoints' + +export const OCI_FUNCTIONS_SERVICE_ID = 'oci-functions' +export const OCI_FUNCTIONS_MANAGEMENT_POLICY = createOciStaticEndpointPolicy({ + serviceId: OCI_FUNCTIONS_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'regional-oci', +}) +export const OCI_FUNCTIONS_INVOCATION_POLICY = createOciDiscoveredEndpointPolicy({ + serviceId: OCI_FUNCTIONS_SERVICE_ID, + serviceName: 'functions', + hostnameTemplate: 'region-first-oci', + responsePolicy: OCI_FUNCTIONS_MANAGEMENT_POLICY, + source: { kind: 'json', path: ['invokeEndpoint'] }, +}) + +export interface PreparedOciFunctionsClient { + client: OciClient + managementEndpoint: OciPreparedEndpoint +} + +/** Callers supply a resolved, authorized credential and a trusted workspace. */ +export async function prepareOciFunctionsClient(binding: { + credentialId: string + workspaceId: string + region?: string +}): Promise { + const client = await createOciClient({ ...binding, serviceId: OCI_FUNCTIONS_SERVICE_ID }) + const managementEndpoint = await client.prepareStaticEndpoint(OCI_FUNCTIONS_MANAGEMENT_POLICY) + return { client, managementEndpoint } +} + +export class OciFunctionsError extends Error { + constructor( + message: string, + readonly status = 400 + ) { + super(message) + this.name = 'OciFunctionsError' + } +} + +export function ociFunctionsResourcePath(kind: 'applications' | 'functions', id?: string): string { + return `/20181201/${kind}${id === undefined ? '' : `/${encodeURIComponent(id)}`}` +} + +interface ManagementRequest { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + path: string + query?: readonly (readonly [string, string])[] + body?: unknown + ifMatch?: string +} + +/** No write retries: Functions management does not document a retry-token contract. */ +export async function requestOciFunctionsManagement( + prepared: PreparedOciFunctionsClient, + request: ManagementRequest, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const common = { + endpoint: prepared.managementEndpoint, + encodedPath: request.path, + queryPairs: request.query, + headers: request.ifMatch === undefined ? undefined : { 'if-match': request.ifMatch }, + timeoutMs: 30_000, + maxResponseBytes: 6_000_000, + responseHeaders: ['opc-next-page'], + signal, + } + const response = await prepared.client.request( + request.method === 'POST' || request.method === 'PUT' + ? { + ...common, + method: request.method, + body: new TextEncoder().encode(JSON.stringify(request.body)), + contentType: 'application/json', + } + : { ...common, method: request.method } + ) + signal?.throwIfAborted() + const expected = + request.method === 'DELETE' || request.path.endsWith('/actions/changeCompartment') ? 204 : 200 + if (response.status !== expected) + throw new OciFunctionsError('Unexpected OCI Functions response status', 502) + return response +} + +const string = z.string().nullish() +const number = z.number().nullish() +const boolean = z.boolean().nullish() +const strings = z.array(z.string()).nullish() +const tags = z.record(z.string(), z.record(z.string(), z.unknown())).nullish() +const commonResource = { + id: z.string().min(1), + compartmentId: string, + displayName: string, + lifecycleState: string, + timeCreated: string, + timeUpdated: string, + freeformTags: z.record(z.string(), z.string()).nullish(), + definedTags: tags, +} +const applicationSummary = z.object({ + ...commonResource, + subnetIds: strings, + shape: string, + networkSecurityGroupIds: strings, + traceConfig: z.object({ domainId: string, isEnabled: boolean }).nullish(), + logging: z.object({ lineFormat: string }).nullish(), + imagePolicyConfig: z + .object({ + isPolicyEnabled: boolean, + keyDetails: z.array(z.object({ kmsKeyId: string })).nullish(), + }) + .nullish(), + securityAttributes: z + .record(z.string(), z.record(z.string(), z.object({ value: string, mode: string }))) + .nullish(), +}) +const applicationResource = applicationSummary.extend({ + config: z.record(z.string(), z.string()).nullish(), + syslogUrl: string, +}) +const destination = z.object({ + destinationType: z.string(), + streamId: string, + queueId: string, + channelId: string, + topicId: string, +}) +const functionSummary = z.object({ + ...commonResource, + applicationId: string, + image: string, + imageDigest: string, + invokeEndpoint: string, + memoryInMBs: number, + timeoutInSeconds: number, + detachedModeTimeoutInSeconds: number, + shape: string, + provisionedConcurrencyConfig: z.object({ strategy: z.string(), count: number }).nullish(), + failureDestination: destination.nullish(), + successDestination: destination.nullish(), + sourceDetails: z.object({ sourceType: z.string(), pbfListingId: string }).nullish(), + traceConfig: z.object({ isEnabled: boolean }).nullish(), +}) +const functionResource = functionSummary.extend({ + config: z.record(z.string(), z.string()).nullish(), +}) + +/** Strip undocumented provider fields while retaining documented dynamic configuration maps. */ +export function projectOciFunctionsResource( + response: OciAuthenticatedResponse, + kind: 'applications' | 'functions', + list = false +) { + const schema = + kind === 'applications' + ? list + ? applicationSummary + : applicationResource + : list + ? functionSummary + : functionResource + try { + const raw: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(response.body)) + return list ? z.array(schema).parse(raw) : schema.parse(raw) + } catch { + throw new OciFunctionsError('Invalid OCI Functions resource response', 502) + } +} + +export function ociFunctionsResponseMetadata(response: OciAuthenticatedResponse) { + return { + status: response.status, + ...(response.opcRequestId ? { opcRequestId: response.opcRequestId } : {}), + ...(response.headers.etag ? { etag: response.headers.etag } : {}), + ...(response.headers['opc-next-page'] ? { nextPage: response.headers['opc-next-page'] } : {}), + } +} diff --git a/apps/sim/lib/internal/oci-functions/execute-tool.test.ts b/apps/sim/lib/internal/oci-functions/execute-tool.test.ts new file mode 100644 index 00000000000..316029456ab --- /dev/null +++ b/apps/sim/lib/internal/oci-functions/execute-tool.test.ts @@ -0,0 +1,287 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + create: vi.fn(), + request: vi.fn(), + prepare: vi.fn(), + discover: vi.fn(), +})) +vi.mock('@/lib/auth/credential-access', () => ({ authorizeCredentialUseForAuth: mocks.authorize })) +vi.mock('@/lib/auth/hybrid', () => ({ AuthType: { INTERNAL_JWT: 'internal_jwt' } })) +vi.mock('@/lib/internal/oci/client.server', () => ({ createOciClient: mocks.create })) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + processSingleFileToUserFile: vi.fn(), + isInternalFileUrl: () => true, +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadServableFileFromStorage: vi.fn(), +})) +vi.mock('@/app/api/files/authorization', () => ({ assertToolFileAccess: vi.fn() })) + +import { OciClientError } from '@/lib/internal/oci/errors' +import { executeOciFunctionsTool } from '@/lib/internal/oci-functions/execute-tool' +import { ociFunctionsInputSchemas } from '@/lib/internal/oci-functions/input' +import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/types' +import { OciFunctionsBlock } from '@/blocks/blocks/oci_functions' +import { ociFunctionsCreateApplicationTool } from '@/tools/oci_functions/create_application' +import { ociFunctionsInvokeTool } from '@/tools/oci_functions/invoke' +import { ociFunctionsListApplicationsTool } from '@/tools/oci_functions/list_applications' +import { ociFunctionsUpdateApplicationTool } from '@/tools/oci_functions/update_application' + +function request(overrides: Partial = {}): InternalToolOperationCall { + return { + toolId: 'oci_functions_get_function', + input: { oauthCredential: 'supplied-pointer', functionId: 'function-1' }, + context: { workflowId: 'workflow-1', workspaceId: 'trusted-workspace', userId: 'trusted-user' }, + headers: new Headers(), + requestId: 'sim-request', + ...overrides, + } +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.authorize.mockResolvedValue({ + ok: true, + resolvedCredentialId: 'authorized-id', + credentialType: 'service_account', + workspaceId: 'trusted-workspace', + }) + mocks.create.mockResolvedValue({ + prepareStaticEndpoint: mocks.prepare, + request: mocks.request, + prepareDiscoveredEndpoint: mocks.discover, + }) + mocks.prepare.mockResolvedValue({ origin: 'management' }) + mocks.discover.mockResolvedValue({ origin: 'invocation' }) + mocks.request.mockResolvedValue({ + status: 200, + headers: {}, + body: new TextEncoder().encode('{"id":"function-1"}'), + }) +}) + +describe('OCI Functions credential execution boundary', () => { + it.each([null, '', undefined])( + 'normalizes native mutation and invocation defaults (%s)', + (blank) => { + for (const [tool, operation, configuration] of [ + [ociFunctionsCreateApplicationTool, 'create_application', blank], + [ociFunctionsUpdateApplicationTool, 'update_application', { config: {} }], + [ociFunctionsInvokeTool, 'invoke', undefined], + ] as const) { + const raw = { + operation, + oauthCredential: 'supplied-pointer', + ociRegion: null, + compartmentId: 'compartment', + applicationId: 'application', + functionId: 'function', + displayName: 'Synthetic', + subnetIds: '["subnet"]', + shape: blank, + configuration, + ifMatch: blank, + dryRun: blank, + intent: blank, + contentType: blank, + payloadType: 'json', + payload: { nested: null }, + } + const params = { ...raw, ...OciFunctionsBlock.tools.config!.params!(raw) } + const input = tool.operation.input(params) + const parsed = ociFunctionsInputSchemas[`oci_functions_${operation}`].parse(input) + expect('ifMatch' in parsed ? parsed.ifMatch : undefined).toBeUndefined() + if (operation === 'invoke') { + expect(parsed).toMatchObject({ dryRun: false, payload: { nested: null } }) + } + if (operation === 'create_application') { + expect('configuration' in parsed ? parsed.configuration : undefined).toBeUndefined() + } + } + expect(() => + OciFunctionsBlock.tools.config!.params!({ operation: 'invoke', dryRun: 'yes' }) + ).toThrow('Dry run must be true or false') + } + ) + + it.each([null, '', undefined, 'application-name'])( + 'normalizes optional list filters through the native merge (%s)', + async (displayName) => { + mocks.request.mockResolvedValue({ + status: 200, + headers: {}, + body: new TextEncoder().encode('[]'), + }) + const raw = { + operation: 'list_applications', + oauthCredential: 'supplied-pointer', + compartmentId: 'compartment', + displayName, + page: null, + sortBy: null, + sortOrder: null, + lifecycleState: null, + id: null, + } + const params = { ...raw, ...OciFunctionsBlock.tools.config?.params?.(raw) } + const result = await executeOciFunctionsTool( + request({ + toolId: ociFunctionsListApplicationsTool.id, + input: ociFunctionsListApplicationsTool.operation.input(params), + }) + ) + expect(result.status).toBe(200) + expect(mocks.request.mock.lastCall?.[0].queryPairs).toEqual([ + ['compartmentId', 'compartment'], + ['limit', '10'], + ...(displayName ? [['displayName', displayName]] : []), + ]) + } + ) + + it.each([null, false, 0])('preserves meaningful invocation JSON payload %s', (payload) => { + const raw = { operation: 'invoke', payloadType: 'json', payload, dryRun: false } + const params = { ...raw, ...OciFunctionsBlock.tools.config?.params?.(raw) } + expect(params.payload).toBe(payload) + expect(params.dryRun).toBe(false) + }) + + it('preserves an empty text invocation payload', () => { + const raw = { operation: 'invoke', payloadType: 'text', payload: '' } + expect({ ...raw, ...OciFunctionsBlock.tools.config?.params?.(raw) }.payload).toBe('') + }) + it('authorizes the supplied pointer but passes only the resolved ID and trusted context into the foundation', async () => { + const result = await executeOciFunctionsTool( + request({ + input: { + oauthCredential: 'supplied-pointer', + accessToken: 'forged-pointer', + credentialId: 'forged-id', + workspaceId: 'forged-workspace', + userId: 'forged-user', + functionId: 'function-1', + region: 'us-phoenix-1', + }, + }) + ) + expect(result.status).toBe(200) + expect(mocks.authorize).toHaveBeenCalledWith( + { success: true, userId: 'trusted-user', authType: 'internal_jwt' }, + { + credentialId: 'supplied-pointer', + workflowId: 'workflow-1', + workspaceId: 'trusted-workspace', + callerUserId: 'trusted-user', + } + ) + expect(mocks.create).toHaveBeenCalledWith({ + credentialId: 'authorized-id', + workspaceId: 'trusted-workspace', + region: 'us-phoenix-1', + serviceId: 'oci-functions', + }) + expect(JSON.stringify(mocks.request.mock.calls)).not.toContain('forged') + }) + + it.each([ + { ok: false }, + { + ok: true, + resolvedCredentialId: 'id', + credentialType: 'oauth', + workspaceId: 'trusted-workspace', + }, + { + ok: true, + resolvedCredentialId: 'id', + credentialType: 'service_account', + workspaceId: 'another-workspace', + }, + { ok: true, credentialType: 'service_account', workspaceId: 'trusted-workspace' }, + ])( + 'rejects denied, wrong-kind, wrong-workspace, or unresolved credentials before creating a provider client', + async (access) => { + mocks.authorize.mockResolvedValue(access) + expect((await executeOciFunctionsTool(request())).status).toBe(403) + expect(mocks.create).not.toHaveBeenCalled() + expect(mocks.request).not.toHaveBeenCalled() + } + ) + + it('requires trusted context instead of accepting caller-provided workspace or user IDs', async () => { + expect( + ( + await executeOciFunctionsTool( + request({ + context: { workflowId: '' }, + input: { + oauthCredential: 'id', + functionId: 'fn', + workspaceId: 'forged', + userId: 'forged', + }, + }) + ) + ).status + ).toBe(403) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('supports authorized workspace-scoped execution without inventing a workflow ID', async () => { + expect( + ( + await executeOciFunctionsTool( + request({ + context: { workflowId: '', workspaceId: 'trusted-workspace', userId: 'trusted-user' }, + }) + ) + ).status + ).toBe(200) + expect(mocks.authorize.mock.calls[0][1].workflowId).toBeUndefined() + }) + + it('rejects invalid operation inputs before authorization or provider access', async () => { + expect( + (await executeOciFunctionsTool(request({ input: { oauthCredential: 'id' } }))).status + ).toBe(400) + expect( + (await executeOciFunctionsTool(request({ toolId: 'oci_functions_unknown' }))).status + ).toBe(400) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('preserves safe Oracle error metadata and marks invocation failures non-retryable', async () => { + mocks.request.mockRejectedValue( + new OciClientError('request_failed', { status: 429, opcRequestId: 'oracle-request' }) + ) + const result = await executeOciFunctionsTool(request({ toolId: 'oci_functions_invoke' })) + expect(result.status).toBe(429) + expect(await result.json()).toEqual({ + success: false, + error: 'OCI request failed', + code: 'request_failed', + status: 429, + opcRequestId: 'oracle-request', + retryable: false, + }) + }) + + it('does not leak unexpected credential-loading or transport errors', async () => { + mocks.create.mockRejectedValue(new Error('private signing material canary')) + const result = await executeOciFunctionsTool(request()) + expect(result.status).toBe(500) + expect(await result.text()).not.toContain('canary') + }) + + it('honors cancellation before authorization', async () => { + const controller = new AbortController() + controller.abort() + await expect(executeOciFunctionsTool(request({ signal: controller.signal }))).rejects.toThrow() + expect(mocks.authorize).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/oci-functions/execute-tool.ts b/apps/sim/lib/internal/oci-functions/execute-tool.ts new file mode 100644 index 00000000000..ea7c3f83799 --- /dev/null +++ b/apps/sim/lib/internal/oci-functions/execute-tool.ts @@ -0,0 +1,195 @@ +import type { z } from 'zod' +import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' +import { AuthType } from '@/lib/auth/hybrid' +import { OciClientError } from '@/lib/internal/oci/errors' +import { OciFunctionsError, prepareOciFunctionsClient } from '@/lib/internal/oci-functions/client' +import { ociFunctionsInputSchemas } from '@/lib/internal/oci-functions/input' +import { + executeOciFunctionsChangeApplicationCompartment, + executeOciFunctionsCreateApplication, + executeOciFunctionsCreateFunction, + executeOciFunctionsDeleteApplication, + executeOciFunctionsDeleteFunction, + executeOciFunctionsGetApplication, + executeOciFunctionsGetFunction, + executeOciFunctionsInvoke, + executeOciFunctionsListApplications, + executeOciFunctionsListFunctions, + executeOciFunctionsUpdateApplication, + executeOciFunctionsUpdateFunction, + type OciFunctionsOperationContext, +} from '@/lib/internal/oci-functions/operations' +import type { + InternalToolOperationCall, + InternalToolOperationHandler, +} from '@/lib/internal/tool-operations/types' + +async function execute, , , ,