diff --git a/apps/docs/content/docs/en/integrations/cloudflare.mdx b/apps/docs/content/docs/en/integrations/cloudflare.mdx index e8c8e34c48d..a7d1257ee7e 100644 --- a/apps/docs/content/docs/en/integrations/cloudflare.mdx +++ b/apps/docs/content/docs/en/integrations/cloudflare.mdx @@ -61,7 +61,7 @@ Lists all zones (domains) in the Cloudflare account. | ↳ `name` | string | Domain name | | ↳ `status` | string | Zone status \(initializing, pending, active, moved\) | | ↳ `paused` | boolean | Whether the zone is paused | -| ↳ `type` | string | Zone type \(full, partial, or secondary\) | +| ↳ `type` | string | Zone type \(full, partial, secondary, or internal\) | | ↳ `name_servers` | array | Assigned Cloudflare name servers | | ↳ `original_name_servers` | array | Original name servers before moving to Cloudflare | | ↳ `created_on` | string | ISO 8601 date when the zone was created | @@ -114,7 +114,7 @@ Gets details for a specific zone (domain) by its ID. | `name` | string | Domain name | | `status` | string | Zone status \(initializing, pending, active, moved\) | | `paused` | boolean | Whether the zone is paused | -| `type` | string | Zone type \(full, partial, or secondary\) | +| `type` | string | Zone type \(full, partial, secondary, or internal\) | | `name_servers` | array | Assigned Cloudflare name servers | | `original_name_servers` | array | Original name servers before moving to Cloudflare | | `created_on` | string | ISO 8601 date when the zone was created | @@ -157,7 +157,7 @@ Adds a new zone (domain) to the Cloudflare account. | --------- | ---- | -------- | ----------- | | `name` | string | Yes | The domain name to add \(e.g., "example.com"\) | | `accountId` | string | Yes | The Cloudflare account ID | -| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\) | +| `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\). Cloudflare also defines "internal", which is not creatable through this tool | | `apiKey` | string | Yes | Cloudflare API Token | #### Output @@ -168,7 +168,7 @@ Adds a new zone (domain) to the Cloudflare account. | `name` | string | Domain name | | `status` | string | Zone status \(initializing, pending, active, moved\) | | `paused` | boolean | Whether the zone is paused | -| `type` | string | Zone type \(full, partial, or secondary\) | +| `type` | string | Zone type \(full, partial, secondary, or internal\) | | `name_servers` | array | Assigned Cloudflare name servers | | `original_name_servers` | array | Original name servers before moving to Cloudflare | | `created_on` | string | ISO 8601 date when the zone was created | @@ -257,7 +257,7 @@ Lists DNS records for a specific zone. | ↳ `proxied` | boolean | Whether Cloudflare proxy is enabled | | ↳ `ttl` | number | TTL in seconds \(1 = automatic\) | | ↳ `locked` | boolean | Whether the record is locked | -| ↳ `priority` | number | MX/SRV record priority | +| ↳ `priority` | number | Record priority, returned for MX and URI records | | ↳ `comment` | string | Comment associated with the record | | ↳ `tags` | array | Tags associated with the record | | ↳ `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified | @@ -326,7 +326,7 @@ Updates an existing DNS record for a zone. | `content` | string | No | DNS record content \(e.g., IP address\) | | `ttl` | number | No | Time to live in seconds \(1 = automatic\) | | `proxied` | boolean | No | Whether to enable Cloudflare proxy | -| `priority` | number | No | Priority for MX and SRV records | +| `priority` | number | No | Record priority. Cloudflare accepts this top-level field for MX and URI records only; an SRV record carries its priority, weight, port, and target inside the record content instead | | `comment` | string | No | Comment for the DNS record | | `tags` | string | No | Comma-separated tags for the DNS record | | `apiKey` | string | Yes | Cloudflare API Token | @@ -345,7 +345,7 @@ Updates an existing DNS record for a zone. | `proxied` | boolean | Whether Cloudflare proxy is enabled | | `ttl` | number | Time to live in seconds \(1 = automatic\) | | `locked` | boolean | Whether the record is locked | -| `priority` | number | Priority for MX and SRV records | +| `priority` | number | Record priority, returned for MX and URI records | | `comment` | string | Comment associated with the record | | `tags` | array | Tags associated with the record | | `comment_modified_on` | string | ISO 8601 timestamp when the comment was last modified | @@ -440,25 +440,29 @@ Lists SSL/TLS certificate packs for a zone. ### Cloudflare Get Zone Settings -Gets all settings for a zone including SSL mode, caching level, and security settings. +Reads zone settings such as SSL mode, minimum TLS version, security level, and caching level. Cloudflare retired the endpoint that read every setting in one request, so each setting is read individually — name the ones you need to keep the read small. Defaults to $\{DEFAULT_ZONE_SETTING_IDS.join(', ')\}. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `zoneId` | string | Yes | The zone ID to get settings for | +| `settingIds` | string | No | Comma-separated setting IDs to read, e.g. "ssl,min_tls_version,security_level". Leave blank to read the default set \($\{DEFAULT_ZONE_SETTING_IDS.join\(', '\)\}\). At most $\{MAX_ZONE_SETTING_IDS\} settings per call. | | `apiKey` | string | Yes | Cloudflare API Token | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `settings` | array | List of zone settings | +| `settings` | array | The zone settings that were readable | | ↳ `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level, always_use_https\) | | ↳ `value` | string | Setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified \(e.g., \{"css":"on","html":"on","js":"on"\}\). | | ↳ `editable` | boolean | Whether the setting can be modified for the current zone plan | | ↳ `modified_on` | string | ISO 8601 timestamp when the setting was last modified | -| ↳ `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) | +| ↳ `time_remaining` | number | Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires \(positive\) or last expired \(negative\) | +| `unreadable` | array | Requested settings Cloudflare refused, typically because the zone plan does not expose them or the setting ID does not exist | +| ↳ `id` | string | The requested setting identifier | +| ↳ `error` | string | Why Cloudflare would not return the setting | ### Cloudflare Update Zone Setting @@ -481,7 +485,7 @@ Updates a specific zone setting such as SSL mode, security level, cache level, o | `value` | string | Updated setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified. | | `editable` | boolean | Whether the setting can be modified for the current zone plan | | `modified_on` | string | ISO 8601 timestamp when the setting was last modified | -| `time_remaining` | number | Seconds remaining until the setting can be modified again \(only present for rate-limited settings\) | +| `time_remaining` | number | Development mode countdown, in seconds. Cloudflare documents this only on the zones_development_mode setting, where it is the interval from when development mode expires \(positive\) or last expired \(negative\) | ### Cloudflare DNS Analytics @@ -494,7 +498,7 @@ Gets DNS analytics report for a zone including query counts and trends. | `zoneId` | string | Yes | The zone ID to get DNS analytics for | | `since` | string | No | Start date for analytics \(ISO 8601, e.g., "2024-01-01T00:00:00Z"\) or relative \(e.g., "-6h"\) | | `until` | string | No | End date for analytics \(ISO 8601, e.g., "2024-01-31T23:59:59Z"\) or relative \(e.g., "now"\) | -| `metrics` | string | No | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\). Optional — Cloudflare returns its default metric set when it is omitted | +| `metrics` | string | No | Comma-separated metrics to retrieve \(e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"\). Optional in the API | | `dimensions` | string | No | Comma-separated dimensions to group by \(e.g., "queryName,queryType,responseCode,responseCached,coloName,origin,dayOfWeek,tcp,ipVersion,querySizeBucket,responseSizeBucket"\) | | `filters` | string | No | Filters to apply to the data \(e.g., "queryType==A"\) | | `sort` | string | No | Sort order for the result set. Fields must be included in metrics or dimensions \(e.g., "+queryCount" or "-responseTimeAvg"\) | @@ -505,30 +509,16 @@ Gets DNS analytics report for a zone including query counts and trends. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `totals` | object | Aggregate DNS analytics totals for the entire queried period | -| ↳ `queryCount` | number | Total number of DNS queries | -| ↳ `uncachedCount` | number | Number of uncached DNS queries | -| ↳ `staleCount` | number | Number of stale DNS queries | +| `totals` | object | Aggregate DNS analytics totals for the entire queried period. Only the metrics that were requested are present. | +| ↳ `queryCount` | number | Total number of DNS queries. Absent when queryCount was not requested | +| ↳ `uncachedCount` | number | Number of uncached DNS queries. Absent when uncachedCount was not requested | +| ↳ `staleCount` | number | Number of stale DNS queries. Absent when staleCount was not requested | | ↳ `responseTimeAvg` | number | Average response time in milliseconds | | ↳ `responseTimeMedian` | number | Median response time in milliseconds | | ↳ `responseTime90th` | number | 90th percentile response time in milliseconds | | ↳ `responseTime99th` | number | 99th percentile response time in milliseconds | -| `min` | object | Minimum values across the analytics period | -| ↳ `queryCount` | number | Minimum number of DNS queries | -| ↳ `uncachedCount` | number | Minimum number of uncached DNS queries | -| ↳ `staleCount` | number | Minimum number of stale DNS queries | -| ↳ `responseTimeAvg` | number | Minimum average response time in milliseconds | -| ↳ `responseTimeMedian` | number | Minimum median response time in milliseconds | -| ↳ `responseTime90th` | number | Minimum 90th percentile response time in milliseconds | -| ↳ `responseTime99th` | number | Minimum 99th percentile response time in milliseconds | -| `max` | object | Maximum values across the analytics period | -| ↳ `queryCount` | number | Maximum number of DNS queries | -| ↳ `uncachedCount` | number | Maximum number of uncached DNS queries | -| ↳ `staleCount` | number | Maximum number of stale DNS queries | -| ↳ `responseTimeAvg` | number | Maximum average response time in milliseconds | -| ↳ `responseTimeMedian` | number | Maximum median response time in milliseconds | -| ↳ `responseTime90th` | number | Maximum 90th percentile response time in milliseconds | -| ↳ `responseTime99th` | number | Maximum 99th percentile response time in milliseconds | +| `min` | json | Per-metric minimums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon. | +| `max` | json | Per-metric maximums. Cloudflare documents this field as currently always an empty object, so treat a populated value as unexpected rather than relied upon. | | `data` | array | Raw analytics data rows returned by the Cloudflare DNS analytics report | | ↳ `dimensions` | array | Dimension values for this data row, parallel to the requested dimensions list | | ↳ `metrics` | array | Metric values for this data row, parallel to the requested metrics list | @@ -908,7 +898,7 @@ Creates a rate limiting rule in the http_ratelimit phase entry point ruleset of | `zoneId` | string | Yes | The zone ID to add the rate limiting rule to | | `rulesetId` | string | Yes | The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules" | | `expression` | string | Yes | Cloudflare filter expression selecting the requests the rule applies to, e.g. \(http.request.uri.path matches "^/api/"\) | -| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id. Example: cf.colo.id,ip.src | +| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. Example: cf.colo.id,ip.src | | `period` | number | Yes | Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600 | | `requestsPerPeriod` | number | Yes | Number of requests allowed within the counting period before the action fires | | `action` | string | No | Action applied once the limit is exceeded, e.g. block, managed_challenge, js_challenge, challenge, or log. Defaults to block | @@ -956,7 +946,7 @@ Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of | `rulesetId` | string | Yes | The http_ratelimit entry point ruleset ID, as returned by "List Rate Limiting Rules" | | `ruleId` | string | Yes | The rate limiting rule ID to update | | `expression` | string | Yes | Cloudflare filter expression selecting the requests the rule applies to | -| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory, plus exactly one of ip.src or cf.unique_visitor_id | +| `characteristics` | string | Yes | Comma-separated counting characteristics. cf.colo.id is mandatory. ip.src and cf.unique_visitor_id are mutually exclusive — include at most one. | | `period` | number | Yes | Counting window in seconds. Cloudflare accepts only 10, 60, 120, 300, 600, or 3600 | | `requestsPerPeriod` | number | Yes | Number of requests allowed within the counting period before the action fires | | `action` | string | Yes | Action applied once the limit is exceeded: block, managed_challenge, js_challenge, challenge, or log. Required because this endpoint replaces the rule rather than merging into it — a defaulted action would silently convert an existing log or challenge rule into a hard block | @@ -965,6 +955,9 @@ Updates a rate limiting rule in the http_ratelimit phase entry point ruleset of | `requestsToOrigin` | boolean | No | When true, only requests that reach the origin are counted | | `description` | string | No | Human-readable description of the rule | | `enabled` | boolean | No | Whether the rule is enabled | +| `ref` | string | No | Reference tag that stays stable across rule updates. Because the update replaces the rule, omitting it resets the tag to the rule ID and breaks anything matching on the old value | +| `actionParameters` | string | No | JSON object of action-specific parameters for the mitigation action, e.g. \{"response":\{"status_code":429,"content":"\{\\"error\\":\\"rate limited\\"\}","content_type":"application/json"\}\} for a custom block response. Because the update replaces the rule, omitting it resets action_parameters to \{\} and the rule falls back to Cloudflare\'s default block page | +| `logging` | string | No | JSON logging configuration to preserve, e.g. \{"enabled":true\}. Omitting it on a rule that had logging configured resets it to the default | | `apiKey` | string | Yes | Cloudflare API Token | #### Output @@ -1114,7 +1107,7 @@ Creates a Cloudflare Access (Zero Trust) application that puts an identity check ### Cloudflare Update Access Application -Updates a Cloudflare Access (Zero Trust) application. This replaces the application definition rather than merging it, so send every field the application should keep — anything you omit reverts to its default, which can widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit. +Updates a Cloudflare Access (Zero Trust) application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every field the application should keep, because an omitted field may revert to its default and widen or break access. Read the current configuration with "Get Access Application" first. Requires an API token with Account Access: Apps and Policies Edit. #### Input @@ -1256,7 +1249,7 @@ Creates a Cloudflare Access (Zero Trust) policy on an application, deciding who ### Cloudflare Update Access Policy -Updates a Cloudflare Access (Zero Trust) policy on an application. This replaces the policy definition rather than merging it, so send every rule the policy should keep — omitted exclude or require rules are dropped, which can widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit. +Updates a Cloudflare Access (Zero Trust) policy on an application. Cloudflare does not document merge behavior for this PUT, so treat it as a replace: send every rule the policy should keep, because an omitted exclude or require rule may be dropped and widen who gets in. The change applies to live traffic immediately. Read the current policy with "List Access Policies" first. Requires an API token with Account Access: Apps and Policies Edit. #### Input diff --git a/apps/docs/content/docs/en/integrations/crowdstrike.mdx b/apps/docs/content/docs/en/integrations/crowdstrike.mdx index 58a69fea6a1..87637a0f06e 100644 --- a/apps/docs/content/docs/en/integrations/crowdstrike.mdx +++ b/apps/docs/content/docs/en/integrations/crowdstrike.mdx @@ -49,7 +49,7 @@ Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/i | `clientId` | string | Yes | CrowdStrike Falcon API client ID | | `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | | `cloud` | string | Yes | CrowdStrike Falcon cloud region | -| `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent_no_ui, prevent, detect; severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. | +| `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect \(prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts\); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. | | `comment` | string | No | Audit comment explaining why these indicators were created | | `retrodetects` | boolean | No | Whether to generate retroactive detections for the new indicators | | `ignoreWarnings` | boolean | No | Whether to create the indicators even when CrowdStrike returns warnings | @@ -774,7 +774,7 @@ Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Q | `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | | `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over IOC fields | -| `limit` | number | No | Maximum number of IOC IDs to return \(1-500, default 100\) | +| `limit` | number | No | Maximum number of IOC IDs to return \(default 100\). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded | | `offset` | number | No | Pagination offset. Mutually exclusive with the after cursor; use after beyond 10,000 IOCs. | | `after` | string | No | Pagination cursor from a previous response. Mutually exclusive with offset. | | `sort` | string | No | Sort expression. Supported fields include action, applied_globally, created_by, created_on, expiration, expired, modified_by, modified_on, severity_number, source, type, and value. | diff --git a/apps/docs/content/docs/en/integrations/datadog.mdx b/apps/docs/content/docs/en/integrations/datadog.mdx index af30f7efe9a..f8dabe5b7ea 100644 --- a/apps/docs/content/docs/en/integrations/datadog.mdx +++ b/apps/docs/content/docs/en/integrations/datadog.mdx @@ -95,7 +95,7 @@ Post an event to the Datadog event stream. Use for deployment notifications, ale | `tags` | string | No | Comma-separated list of tags \(e.g., "env:production,service:api", "team:backend,priority:high"\) | | `aggregationKey` | string | No | Key to aggregate events together | | `sourceTypeName` | string | No | Source type name for the event | -| `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\) | +| `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\). Datadog limits this to events no older than 18 hours. | | `apiKey` | string | Yes | Datadog API key | | `site` | string | No | Datadog site/region \(default: datadoghq.com\) | @@ -197,8 +197,8 @@ List all monitors in Datadog with optional filtering by name, tags, or state. | `tags` | string | No | Comma-separated list of tags to filter by \(e.g., "env:prod,team:backend"\) | | `monitorTags` | string | No | Comma-separated list of monitor tags to filter by \(e.g., "service:api,priority:high"\) | | `withDowntimes` | boolean | No | Include downtime data with monitors | -| `page` | number | No | Page number for pagination \(0-indexed, e.g., 0, 1, 2\) | -| `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\) | +| `page` | number | No | Page to start paginating from \(0-indexed, e.g., 0, 1, 2\). Datadog returns every monitor in the org without pagination when this is not specified, so set it to bound the response. Setting Page Size alone implies page 0. | +| `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\). Datadog only applies this when a page is specified — otherwise it returns all monitors with no page size limit — so setting this alone sends page 0. With a page but no page size, Datadog defaults to 100. | | `apiKey` | string | Yes | Datadog API key | | `applicationKey` | string | Yes | Datadog Application key | | `site` | string | No | Datadog site/region \(default: datadoghq.com\) | @@ -312,7 +312,7 @@ Send log entries to Datadog for centralized logging and analysis. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. | +| `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline. | | `apiKey` | string | Yes | Datadog API key | | `site` | string | No | Datadog site/region \(default: datadoghq.com\) | diff --git a/apps/docs/content/docs/en/integrations/microsoft_ad.mdx b/apps/docs/content/docs/en/integrations/microsoft_ad.mdx index 7cbefb0800e..3fff9ba8934 100644 --- a/apps/docs/content/docs/en/integrations/microsoft_ad.mdx +++ b/apps/docs/content/docs/en/integrations/microsoft_ad.mdx @@ -55,6 +55,17 @@ List users in Azure AD (Microsoft Entra ID) | Parameter | Type | Description | | --------- | ---- | ----------- | | `users` | array | List of users | +| ↳ `id` | string | User ID | +| ↳ `displayName` | string | Display name | +| ↳ `givenName` | string | First name | +| ↳ `surname` | string | Last name | +| ↳ `userPrincipalName` | string | User principal name \(email\) | +| ↳ `mail` | string | Email address | +| ↳ `jobTitle` | string | Job title | +| ↳ `department` | string | Department | +| ↳ `officeLocation` | string | Office location | +| ↳ `mobilePhone` | string | Mobile phone number | +| ↳ `accountEnabled` | boolean | Whether the account is enabled | | `userCount` | number | Number of users returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -182,6 +193,16 @@ List groups in Azure AD (Microsoft Entra ID) | Parameter | Type | Description | | --------- | ---- | ----------- | | `groups` | array | List of groups | +| ↳ `id` | string | Group ID | +| ↳ `displayName` | string | Display name | +| ↳ `description` | string | Group description | +| ↳ `mail` | string | Email address | +| ↳ `mailEnabled` | boolean | Whether mail is enabled | +| ↳ `mailNickname` | string | Mail nickname | +| ↳ `securityEnabled` | boolean | Whether security is enabled | +| ↳ `groupTypes` | array | Group types | +| ↳ `visibility` | string | Group visibility | +| ↳ `createdDateTime` | string | Creation date | | `groupCount` | number | Number of groups returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -298,6 +319,10 @@ List members of a group in Azure AD (Microsoft Entra ID) | Parameter | Type | Description | | --------- | ---- | ----------- | | `members` | array | List of group members | +| ↳ `id` | string | Member ID | +| ↳ `displayName` | string | Display name | +| ↳ `mail` | string | Email address | +| ↳ `odataType` | string | Directory object type | | `memberCount` | number | Number of members returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -360,6 +385,8 @@ Add or remove subscription licenses (SKUs) on a user in Microsoft Entra ID. Remo | `displayName` | string | Display name of the user | | `userPrincipalName` | string | User principal name of the user | | `assignedLicenses` | array | Licenses assigned to the user after the change | +| ↳ `skuId` | string | SKU ID of the assigned license | +| ↳ `disabledPlans` | array | Service plan IDs disabled on this license | ### List Microsoft Entra ID User Licenses @@ -376,6 +403,14 @@ List the subscription licenses assigned to a user in Microsoft Entra ID | Parameter | Type | Description | | --------- | ---- | ----------- | | `licenses` | array | Licenses assigned to the user | +| ↳ `id` | string | License detail ID | +| ↳ `skuId` | string | SKU ID of the license | +| ↳ `skuPartNumber` | string | SKU part number \(e.g., "ENTERPRISEPACK"\) | +| ↳ `servicePlans` | array | Service plans included in the license | +| ↳ `servicePlanId` | string | Service plan ID | +| ↳ `servicePlanName` | string | Service plan name | +| ↳ `provisioningStatus` | string | Provisioning status of the service plan | +| ↳ `appliesTo` | string | Whether the plan applies to "User" or "Company" | | `licenseCount` | number | Number of licenses returned | ### List Microsoft Entra ID Subscribed SKUs @@ -392,6 +427,22 @@ List the subscription SKUs the tenant owns, including how many license units are | Parameter | Type | Description | | --------- | ---- | ----------- | | `skus` | array | Subscription SKUs owned by the tenant | +| ↳ `id` | string | Subscribed SKU object ID | +| ↳ `skuId` | string | SKU ID, used when assigning or removing licenses | +| ↳ `skuPartNumber` | string | SKU part number \(e.g., "ENTERPRISEPACK"\) | +| ↳ `appliesTo` | string | Whether the SKU applies to "User" or "Company" | +| ↳ `capabilityStatus` | string | Capability status of the subscription | +| ↳ `consumedUnits` | number | Number of licenses currently assigned | +| ↳ `prepaidUnits` | object | Prepaid license unit counts by status | +| ↳ `enabled` | number | Number of units that are enabled | +| ↳ `suspended` | number | Number of units that are suspended | +| ↳ `warning` | number | Number of units that are in warning status | +| ↳ `lockedOut` | number | Number of units that are locked out | +| ↳ `servicePlans` | array | Service plans included in the SKU | +| ↳ `servicePlanId` | string | Service plan ID | +| ↳ `servicePlanName` | string | Service plan name | +| ↳ `provisioningStatus` | string | Provisioning status of the service plan | +| ↳ `appliesTo` | string | Whether the plan applies to "User" or "Company" | | `skuCount` | number | Number of SKUs returned | ### Revoke Microsoft Entra ID Sign-In Sessions @@ -467,6 +518,9 @@ List the authentication methods a user has registered, such as passwords, phone | Parameter | Type | Description | | --------- | ---- | ----------- | | `methods` | array | Authentication methods registered by the user | +| ↳ `id` | string | Authentication method ID | +| ↳ `odataType` | string | Authentication method type \(e.g., "#microsoft.graph.phoneAuthenticationMethod"\). Method-specific details vary by type. | +| ↳ `createdDateTime` | string | When the authentication method was registered | | `methodCount` | number | Number of authentication methods returned | ### List Microsoft Entra ID Sign-Ins @@ -486,6 +540,34 @@ List sign-in events from the Microsoft Entra ID sign-in logs, newest first. Requ | Parameter | Type | Description | | --------- | ---- | ----------- | | `signIns` | array | Sign-in events | +| ↳ `id` | string | Sign-in event ID | +| ↳ `createdDateTime` | string | When the sign-in was initiated | +| ↳ `userId` | string | ID of the user who signed in | +| ↳ `userDisplayName` | string | Display name of the user | +| ↳ `userPrincipalName` | string | User principal name of the user | +| ↳ `appId` | string | ID of the application used to sign in | +| ↳ `appDisplayName` | string | Display name of the application | +| ↳ `resourceId` | string | ID of the resource that was accessed | +| ↳ `resourceDisplayName` | string | Display name of the resource | +| ↳ `ipAddress` | string | IP address the sign-in came from | +| ↳ `clientAppUsed` | string | Legacy client app used to sign in | +| ↳ `correlationId` | string | Correlation ID for the sign-in request | +| ↳ `conditionalAccessStatus` | string | Conditional access result: success, failure, notApplied, or unknownFutureValue | +| ↳ `isInteractive` | boolean | Whether the sign-in was interactive | +| ↳ `riskDetail` | string | Reason behind a specific risk state | +| ↳ `riskLevelAggregated` | string | Aggregated risk level for the sign-in | +| ↳ `riskState` | string | Risk state of the user or sign-in | +| ↳ `errorCode` | number | Sign-in status error code. 0 indicates a successful sign-in. | +| ↳ `failureReason` | string | Failure reason from the sign-in status | +| ↳ `deviceDisplayName` | string | Display name of the device used | +| ↳ `deviceId` | string | ID of the device used | +| ↳ `deviceOperatingSystem` | string | Operating system of the device used | +| ↳ `deviceBrowser` | string | Browser used to sign in | +| ↳ `deviceIsCompliant` | boolean | Whether the device is compliant | +| ↳ `deviceIsManaged` | boolean | Whether the device is managed | +| ↳ `locationCity` | string | City the sign-in came from | +| ↳ `locationState` | string | State the sign-in came from | +| ↳ `locationCountryOrRegion` | string | Two-letter country or region code the sign-in came from | | `signInCount` | number | Number of sign-ins returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -506,6 +588,25 @@ List directory audit records showing who changed what in Microsoft Entra ID, suc | Parameter | Type | Description | | --------- | ---- | ----------- | | `audits` | array | Directory audit records | +| ↳ `id` | string | Audit record ID | +| ↳ `activityDateTime` | string | When the activity took place | +| ↳ `activityDisplayName` | string | Name of the activity | +| ↳ `category` | string | Category of the activity | +| ↳ `correlationId` | string | Correlation ID for the activity | +| ↳ `loggedByService` | string | Service that logged the activity | +| ↳ `operationType` | string | Operation type \(e.g., Add, Update, Delete\) | +| ↳ `result` | string | Result of the activity: success, failure, timeout, or unknownFutureValue | +| ↳ `resultReason` | string | Reason for the result | +| ↳ `initiatedByUserId` | string | ID of the user who initiated the activity | +| ↳ `initiatedByUserPrincipalName` | string | User principal name of the initiating user | +| ↳ `initiatedByUserDisplayName` | string | Display name of the initiating user | +| ↳ `initiatedByAppId` | string | App ID that initiated the activity | +| ↳ `initiatedByAppDisplayName` | string | Display name of the app that initiated the activity | +| ↳ `targetResources` | array | Resources the activity acted on | +| ↳ `id` | string | ID of the target resource | +| ↳ `displayName` | string | Display name of the target resource | +| ↳ `type` | string | Type of the target resource \(e.g., User, Group\) | +| ↳ `userPrincipalName` | string | User principal name of the target, null for non-user resources | | `auditCount` | number | Number of audit records returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -527,6 +628,14 @@ List the application role assignments granted to a user, including assignments t | Parameter | Type | Description | | --------- | ---- | ----------- | | `assignments` | array | App role assignments granted to the user | +| ↳ `id` | string | App role assignment ID, used when removing the assignment | +| ↳ `appRoleId` | string | ID of the app role. All-zero GUID means the assignment grants access without a specific role. | +| ↳ `createdDateTime` | string | When the assignment was created | +| ↳ `principalId` | string | ID of the assigned principal | +| ↳ `principalDisplayName` | string | Display name of the assigned principal | +| ↳ `principalType` | string | Principal type: User, Group, or ServicePrincipal | +| ↳ `resourceId` | string | ID of the resource service principal that defines the app role | +| ↳ `resourceDisplayName` | string | Display name of the resource | | `assignmentCount` | number | Number of assignments returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -538,7 +647,7 @@ Grant a user an application role on a service principal, giving them access to t | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `userId` | string | Yes | User ID or user principal name to grant the app role to | +| `userId` | string | Yes | Object ID or user principal name of the user to grant the app role to. A user principal name is resolved to its object ID before the grant. | | `resourceId` | string | Yes | Object ID of the resource service principal that defines the app role. Use List Service Principals to find it. | | `appRoleId` | string | Yes | ID of the app role to grant. Use the all-zero GUID 00000000-0000-0000-0000-000000000000 to assign access without a specific role. | @@ -593,6 +702,21 @@ List the enterprise applications and service principals in the tenant, including | Parameter | Type | Description | | --------- | ---- | ----------- | | `servicePrincipals` | array | Service principals in the tenant | +| ↳ `id` | string | Service principal object ID, used as the resource ID of an app role assignment | +| ↳ `appId` | string | Application ID associated with the service principal | +| ↳ `displayName` | string | Display name of the service principal | +| ↳ `servicePrincipalType` | string | Type of service principal \(e.g., Application, ManagedIdentity, Legacy\) | +| ↳ `accountEnabled` | boolean | Whether users can sign in to the associated application | +| ↳ `appOwnerOrganizationId` | string | Tenant ID where the application is registered | +| ↳ `signInAudience` | string | Which Microsoft accounts are supported by the associated application | +| ↳ `tags` | array | Custom strings used to categorize the service principal | +| ↳ `appRoles` | array | App roles exposed by the associated application | +| ↳ `id` | string | App role ID, used when granting an app role assignment | +| ↳ `displayName` | string | Display name of the app role | +| ↳ `description` | string | Description of the app role | +| ↳ `value` | string | Value included in the roles claim for this app role | +| ↳ `isEnabled` | boolean | Whether the app role can be assigned | +| ↳ `allowedMemberTypes` | array | Principal types the app role can be assigned to \(User and/or Application\) | | `servicePrincipalCount` | number | Number of service principals returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -613,6 +737,14 @@ List every user, group, and service principal assigned to an application, by rea | Parameter | Type | Description | | --------- | ---- | ----------- | | `assignments` | array | Principals assigned to the application | +| ↳ `id` | string | App role assignment ID, used when removing the assignment | +| ↳ `appRoleId` | string | ID of the app role. All-zero GUID means the assignment grants access without a specific role. | +| ↳ `createdDateTime` | string | When the assignment was created | +| ↳ `principalId` | string | ID of the assigned principal | +| ↳ `principalDisplayName` | string | Display name of the assigned principal | +| ↳ `principalType` | string | Principal type: User, Group, or ServicePrincipal | +| ↳ `resourceId` | string | ID of the resource service principal that defines the app role | +| ↳ `resourceDisplayName` | string | Display name of the resource | | `assignmentCount` | number | Number of assignments returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -630,6 +762,10 @@ List the administrator roles that are activated in the tenant, such as Global Ad | Parameter | Type | Description | | --------- | ---- | ----------- | | `roles` | array | Activated directory roles | +| ↳ `id` | string | Directory role object ID | +| ↳ `displayName` | string | Display name of the directory role | +| ↳ `description` | string | Description of the directory role | +| ↳ `roleTemplateId` | string | ID of the directory role template | | `roleCount` | number | Number of directory roles returned | ### List Microsoft Entra ID Directory Role Members @@ -647,6 +783,10 @@ List the principals holding an administrator role. Returns up to 1000 members; t | Parameter | Type | Description | | --------- | ---- | ----------- | | `members` | array | Principals holding the directory role | +| ↳ `id` | string | Member ID | +| ↳ `displayName` | string | Display name | +| ↳ `mail` | string | Email address | +| ↳ `odataType` | string | Directory object type | | `memberCount` | number | Number of members returned | ### Add Microsoft Entra ID Directory Role Member @@ -705,6 +845,20 @@ List the devices registered in Microsoft Entra ID | Parameter | Type | Description | | --------- | ---- | ----------- | | `devices` | array | Registered devices | +| ↳ `id` | string | Device object ID, used to get, update, or delete the device | +| ↳ `deviceId` | string | Unique device identifier set during registration | +| ↳ `displayName` | string | Display name of the device | +| ↳ `operatingSystem` | string | Operating system of the device | +| ↳ `operatingSystemVersion` | string | Operating system version of the device | +| ↳ `accountEnabled` | boolean | Whether the device is enabled | +| ↳ `isCompliant` | boolean | Whether the device complies with MDM policies | +| ↳ `isManaged` | boolean | Whether the device is managed by an MDM app | +| ↳ `trustType` | string | Device registration type: Workplace, AzureAd, or ServerAd | +| ↳ `profileType` | string | Device profile type: RegisteredDevice, SecureVM, Printer, Shared, or IoT | +| ↳ `manufacturer` | string | Manufacturer of the device | +| ↳ `model` | string | Model of the device | +| ↳ `approximateLastSignInDateTime` | string | Approximate time the device last signed in | +| ↳ `registrationDateTime` | string | When the device was registered | | `deviceCount` | number | Number of devices returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -756,6 +910,20 @@ List the devices a user has registered or owns. Devices the caller cannot read a | Parameter | Type | Description | | --------- | ---- | ----------- | | `devices` | array | Devices linked to the user | +| ↳ `id` | string | Device object ID, used to get, update, or delete the device | +| ↳ `deviceId` | string | Unique device identifier set during registration | +| ↳ `displayName` | string | Display name of the device | +| ↳ `operatingSystem` | string | Operating system of the device | +| ↳ `operatingSystemVersion` | string | Operating system version of the device | +| ↳ `accountEnabled` | boolean | Whether the device is enabled | +| ↳ `isCompliant` | boolean | Whether the device complies with MDM policies | +| ↳ `isManaged` | boolean | Whether the device is managed by an MDM app | +| ↳ `trustType` | string | Device registration type: Workplace, AzureAd, or ServerAd | +| ↳ `profileType` | string | Device profile type: RegisteredDevice, SecureVM, Printer, Shared, or IoT | +| ↳ `manufacturer` | string | Manufacturer of the device | +| ↳ `model` | string | Model of the device | +| ↳ `approximateLastSignInDateTime` | string | Approximate time the device last signed in | +| ↳ `registrationDateTime` | string | When the device was registered | | `deviceCount` | number | Number of devices returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | @@ -776,6 +944,15 @@ List the conditional access policies configured in the tenant, including their s | Parameter | Type | Description | | --------- | ---- | ----------- | | `policies` | array | Conditional access policies | +| ↳ `id` | string | Conditional access policy ID | +| ↳ `displayName` | string | Display name of the policy | +| ↳ `state` | string | Policy state: enabled, disabled, or enabledForReportingButNotEnforced | +| ↳ `templateId` | string | ID of the template the policy was created from | +| ↳ `createdDateTime` | string | When the policy was created | +| ↳ `modifiedDateTime` | string | When the policy was last modified | +| ↳ `conditions` | json | Conditions that trigger the policy \(users, applications, platforms, locations, risk levels\) | +| ↳ `grantControls` | json | Controls enforced when the policy applies, or null when none are configured | +| ↳ `sessionControls` | json | Session controls enforced when the policy applies, or null when none are set | | `policyCount` | number | Number of policies returned | | `nextLink` | string | Continuation URL for the next page of results, or null if there are no more | diff --git a/apps/docs/content/docs/en/integrations/mssql.mdx b/apps/docs/content/docs/en/integrations/mssql.mdx index a00619fe009..8bbe43fed5a 100644 --- a/apps/docs/content/docs/en/integrations/mssql.mdx +++ b/apps/docs/content/docs/en/integrations/mssql.mdx @@ -31,7 +31,7 @@ Execute a SELECT query on a Microsoft SQL Server database | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `query` | string | Yes | T-SQL SELECT query to execute, optionally led by a WITH clause. Statements that modify data or schema are rejected — use the Execute Raw SQL operation for those. | @@ -43,6 +43,8 @@ Execute a SELECT query on a Microsoft SQL Server database | `message` | string | Operation status message | | `rows` | array | Array of rows returned from the query | | `rowCount` | number | Number of rows returned | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Insert @@ -57,7 +59,7 @@ Insert data into a Microsoft SQL Server table | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to insert data into | @@ -70,6 +72,8 @@ Insert data into a Microsoft SQL Server table | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement \(empty for a plain INSERT\) | | `rowCount` | number | Number of rows inserted | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Update @@ -84,7 +88,7 @@ Update rows in a Microsoft SQL Server table | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to update | @@ -98,6 +102,8 @@ Update rows in a Microsoft SQL Server table | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement \(empty for a plain UPDATE\) | | `rowCount` | number | Number of rows updated | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Delete @@ -112,7 +118,7 @@ Delete rows from a Microsoft SQL Server table | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `table` | string | Yes | Table name to delete rows from | @@ -125,6 +131,8 @@ Delete rows from a Microsoft SQL Server table | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement \(empty for a plain DELETE\) | | `rowCount` | number | Number of rows deleted | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Execute @@ -139,7 +147,7 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `query` | string | Yes | T-SQL statement to execute | @@ -151,6 +159,8 @@ Execute a raw T-SQL statement on a Microsoft SQL Server database | `message` | string | Operation status message | | `rows` | array | Rows returned by the statement, when it returns a result set | | `rowCount` | number | Number of rows returned or affected | +| `truncated` | boolean | Present and true only when rows were dropped to stay inside the response ceilings. Absent means the recordset is complete | +| `truncationReason` | string | Which ceiling was hit and how to read the remaining rows | ### Microsoft SQL Server Introspect @@ -165,7 +175,7 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns, | `database` | string | Yes | Database name to connect to | | `username` | string | Yes | Database username | | `password` | string | Yes | Database password | -| `encrypt` | string | No | Encrypt the connection with TLS \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext | +| `encrypt` | string | No | Request TLS encryption for the connection \(enabled, disabled\). Defaults to enabled. Disabling sends the login packet and every row in cleartext. Enabling requests encryption over TDS 7.4, which the server negotiates during prelogin - a server that answers NOT_SUP yields an unencrypted session rather than an error, so this is a request, not a guarantee | | `trustServerCertificate` | string | No | Trust a self-signed server certificate \(enabled, disabled\). Defaults to disabled. Enabling skips certificate validation, so the connection is open to a machine-in-the-middle | | `connectionTimeout` | number | No | Connection and request timeout in milliseconds \(default: 15000\) | | `schema` | string | No | Schema to introspect \(default: dbo\) | @@ -176,6 +186,29 @@ Introspect a Microsoft SQL Server schema to retrieve table structures, columns, | --------- | ---- | ----------- | | `message` | string | Operation status message | | `tables` | array | Array of table schemas with columns, keys, and indexes | +| ↳ `name` | string | Table name | +| ↳ `schema` | string | Schema name \(e.g., dbo\) | +| ↳ `columns` | array | Table columns in ordinal position order | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Data type \(e.g., int, nvarchar, datetime2\) | +| ↳ `nullable` | boolean | Whether the column allows NULL values | +| ↳ `default` | string | Default value expression | +| ↳ `isPrimaryKey` | boolean | Whether the column is part of the primary key | +| ↳ `isForeignKey` | boolean | Whether the column is a foreign key | +| ↳ `references` | object | Foreign key reference information | +| ↳ `schema` | string | Referenced schema name | +| ↳ `table` | string | Referenced table name | +| ↳ `column` | string | Referenced column name | +| ↳ `primaryKey` | array | Primary key column names, in key order | +| ↳ `foreignKeys` | array | Foreign key constraints declared on this table | +| ↳ `column` | string | Local column name | +| ↳ `referencesSchema` | string | Referenced schema name | +| ↳ `referencesTable` | string | Referenced table name | +| ↳ `referencesColumn` | string | Referenced column name | +| ↳ `indexes` | array | Non-primary-key rowstore indexes on this table | +| ↳ `name` | string | Index name | +| ↳ `columns` | array | Key columns included in the index, in key order | +| ↳ `unique` | boolean | Whether the index enforces uniqueness | | `schemas` | array | List of available schemas in the database | diff --git a/apps/docs/content/docs/en/integrations/okta.mdx b/apps/docs/content/docs/en/integrations/okta.mdx index 94fc083b92f..5cf8c8e7be7 100644 --- a/apps/docs/content/docs/en/integrations/okta.mdx +++ b/apps/docs/content/docs/en/integrations/okta.mdx @@ -280,7 +280,7 @@ Generate a one-time token to reset a user password. Can email the reset link to | `apiKey` | string | Yes | Okta API token for authentication | | `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) | | `userId` | string | Yes | User ID or login to reset password for | -| `sendEmail` | boolean | No | Send password reset email to the user \(default: true\) | +| `sendEmail` | boolean | No | Send password reset email to the user. Okta requires this parameter and declares no default of its own; leaving it blank sends the email | #### Output @@ -1219,7 +1219,7 @@ Query the Okta System Log for sign-ins, admin changes, and security events. Supp | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Okta API token for authentication | | `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) | -| `since` | string | No | Start of the query time window as an ISO 8601 timestamp \(default: 7 days before "until"\) | +| `since` | string | No | Start of the query time window as an ISO 8601 timestamp \(default: 7 days before "until"\). Ignored when a cursor is supplied in "after", which already encodes the resume position | | `until` | string | No | End of the query time window as an ISO 8601 timestamp \(default: now\) | | `filter` | string | No | SCIM filter expression \(e.g., eventType eq "user.session.start" or outcome.result eq "FAILURE"\) | | `q` | string | No | Keyword search across the event payload \(max 40 characters per keyword, max 10 keywords\) | @@ -1267,8 +1267,8 @@ Query the Okta System Log for sign-ins, admin changes, and security events. Supp | ↳ `displayName` | string | Target display name | | ↳ `debugData` | json | Extra context whose keys depend on the event type. Okta states these keys and values can change between releases, so treat them as a debugging aid rather than a contract | | `count` | number | Number of events returned | -| `nextCursor` | string | Cursor for the next page, or null on the last page | -| `hasMore` | boolean | Whether more events are available | +| `nextCursor` | string | Cursor to resume from, or null when Okta advertised no next link. On a polling query it stays set on an empty page so the next scheduled run resumes from here rather than replaying from the start | +| `hasMore` | boolean | Whether more events are available. A query with no "until" is a polling query, which Okta always answers with a next link even when there are no new events, so this reports false once a page comes back empty | | `success` | boolean | Operation success status | diff --git a/apps/docs/content/docs/en/integrations/servicenow.mdx b/apps/docs/content/docs/en/integrations/servicenow.mdx index acce8017734..2359530996b 100644 --- a/apps/docs/content/docs/en/integrations/servicenow.mdx +++ b/apps/docs/content/docs/en/integrations/servicenow.mdx @@ -354,7 +354,7 @@ Search ServiceNow incidents by state, priority, assignment, caller, or text. All | `callerId` | string | No | sys_id of the caller. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) incidents. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -739,7 +739,7 @@ Search ServiceNow change requests by state, type, risk, assignment, or text. All | `assignedTo` | string | No | sys_id of the assigned user. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) change requests. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1032,7 +1032,7 @@ List requested items (RITMs) from the ServiceNow Requested Item [sc_req_item] ta | `catalogItemSysId` | string | No | sys_id of the catalog item \(cat_item\) to filter by. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) requested items. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1132,7 +1132,7 @@ List approval records from the ServiceNow Approval [sysapproval_approver] table. | `state` | string | No | Approval state: "requested" \(pending, the default\), "approved", or "rejected". Pass an empty string with a custom query to list every state. | | `approvalFor` | string | No | sys_id of the record being approved, matched against the sysapproval reference field. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1234,7 +1234,7 @@ Search the ServiceNow CMDB for configuration items. Defaults to the base cmdb_ci | `name` | string | No | Text to match against the CI name using the ServiceNow LIKE operator, which matches anywhere in the field. | | `operationalStatus` | string | No | Operational status coded value \(operational_status\). The choice list is configured per instance. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1316,7 +1316,7 @@ List rows from the CI Relationship [cmdb_rel_ci] table for a configuration item. | `ciSysId` | string | Yes | sys_id of the configuration item whose relationships should be listed. | | `direction` | string | No | Which side of the relationship the CI sits on: "parent", "child", or "both" \(default\). "both" matches rows where the CI is either the parent or the child. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1436,7 +1436,7 @@ Look up ServiceNow users by email, user name, or display name. Use this to resol | `name` | string | No | Text to match against the display name using the ServiceNow LIKE operator, which matches anywhere in the field. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) users. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | @@ -1487,7 +1487,7 @@ List the members of a ServiceNow group from the Group Member [sys_user_grmember] | `groupSysId` | string | No | sys_id of the sys_user_group whose members should be listed. | | `groupName` | string | No | Exact group name, resolved against the referenced group record. Provide this or the group sys_id. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | -| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). | +| `limit` | number | No | Maximum number of records to return \(sysparm_limit\). Omitting it sends no limit at all, and the Table API then applies its own default of 10,000 records, so always set it to what you will actually read. | | `offset` | number | No | Number of records to skip for pagination \(sysparm_offset\). | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | | `displayValue` | string | No | How reference and choice fields are returned: "all" \(default — both the sys_id and the label, as \{value, display_value\}\), "true" \(labels only\), or "false" \(raw sys_ids and coded values only\). | diff --git a/apps/docs/content/docs/en/integrations/splunk.mdx b/apps/docs/content/docs/en/integrations/splunk.mdx index 8c5a98c001f..03c872e8d61 100644 --- a/apps/docs/content/docs/en/integrations/splunk.mdx +++ b/apps/docs/content/docs/en/integrations/splunk.mdx @@ -20,7 +20,7 @@ Integrate Splunk Enterprise or Splunk Cloud into workflows. Run SPL searches syn ### Splunk Run Search -Run an SPL search synchronously and return its results in a single call (oneshot mode). Use for short searches; use Create Search Job for long-running ones. +Run an SPL search synchronously and return its results in a single call (oneshot mode). A oneshot search buffers the whole result set in one response with no paging, so use it for short searches; for anything large use Create Search Job with Get Search Results, which defaults to 100 rows and pages with offset. #### Input @@ -37,62 +37,19 @@ Run an SPL search synchronously and return its results in a single call (oneshot | `latestTime` | string | No | Latest \(exclusive\) time bound — relative \(e.g. now\) or absolute time | | `adhocSearchLevel` | string | No | Search mode: verbose, fast, or smart. Defaults to fast. | | `autoCancel` | number | No | Cancel the search after this many seconds of inactivity \(e.g. 60\). 0 never auto-cancels. | -| `maxCount` | number | No | Maximum number of results the search stores and returns. Defaults to 10000. Lower it to bound large oneshot responses. | +| `maxCount` | number | No | Number of events accessible in any given status bucket, and in transforming mode the maximum number of results to store. Defaults to 10000. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `results` | json | Search result rows, each holding the fields the search produced | -| `resultCount` | number | Number of result rows returned | -| `preview` | boolean | Whether the results are previews | -| `initOffset` | number | Offset of the first returned row | -| `messages` | json | Messages returned with the response \(\[\{type, text\}\]\) | -| `sid` | string | Search ID of the job | -| `label` | string | Custom name of the search job | -| `dispatchState` | string | Current state of the search job | -| `doneProgress` | number | Approximate job progress between 0 and 1 | -| `isDone` | boolean | Whether the search has completed | -| `isFailed` | boolean | Whether the search failed | -| `isFinalized` | boolean | Whether the search was finalized | -| `isPaused` | boolean | Whether the search is paused | -| `isZombie` | boolean | Whether the search process died | -| `isSaved` | boolean | Whether the job artifacts are saved | -| `isSavedSearch` | boolean | Whether the job came from a saved search | -| `isRealTimeSearch` | boolean | Whether this is a real-time search | -| `eventCount` | number | Number of events returned | -| `eventAvailableCount` | number | Number of events available for export | -| `eventFieldCount` | number | Number of fields found in the results | -| `resultPreviewCount` | number | Number of rows in the latest preview | -| `scanCount` | number | Number of events scanned off disk | -| `runDuration` | number | Seconds the search took to complete | -| `priority` | number | Search priority between 0 and 10 | -| `earliestTime` | string | Earliest time bound of the job | -| `latestTime` | string | Latest time bound of the job | -| `searchEarliestTime` | string | Earliest time as specified in the search command | -| `searchLatestTime` | string | Latest time as specified in the search command | -| `savedSearches` | json | Saved searches \(\[\{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType\}\]\) | -| `name` | string | Saved search name | -| `id` | string | Fully qualified REST URI of the resource | -| `author` | string | Owner of the saved search | -| `updated` | string | Last update timestamp | -| `search` | string | SPL the saved search runs | -| `qualifiedSearch` | string | Exact search string the scheduler runs | -| `description` | string | Saved search description | -| `disabled` | boolean | Whether the saved search is disabled | -| `isScheduled` | boolean | Whether the search runs on a schedule | -| `isVisible` | boolean | Whether the search is listed as visible | -| `cronSchedule` | string | Cron schedule for the search | -| `nextScheduledTime` | string | Next scheduled run time | -| `alertType` | string | Alert condition type | -| `dispatchEarliestTime` | string | Earliest time used when dispatching | -| `dispatchLatestTime` | string | Latest time used when dispatching | -| `alerts` | json | Saved searches with currently triggered alerts \(\[\{name, id, updated, triggeredAlertCount\}\]\) | -| `firedAlerts` | json | Triggered instances of an alert \(\[\{name, savedSearchName, alertType, severity, sid, triggerTime\}\]\) | -| `indexes` | json | Indexes configured on the instance \(\[\{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime\}\]\) | -| `apps` | json | Apps installed on the instance \(name, label, version, author, disabled\) | -| `total` | number | Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains. | -| `offset` | number | Offset of the first entry in the returned page, from the paging envelope | +| `results` | array | Result rows. Each row holds the fields produced by the search. | +| `resultCount` | number | Number of result rows returned in this response | +| `preview` | boolean | Whether these are preview results from a still-running job | +| `initOffset` | number | Offset of the first returned row within the full result set | +| `messages` | array | Search messages returned alongside the results | +| ↳ `type` | string | Message severity | +| ↳ `text` | string | Message text | ### Splunk Create Search Job @@ -169,8 +126,8 @@ Get the status and progress of a Splunk search job by search ID, including dispa | `priority` | number | Search priority between 0 and 10 | | `earliestTime` | string | Earliest \(inclusive\) time bound for the search | | `latestTime` | string | Latest \(exclusive\) time bound for the search | -| `searchEarliestTime` | string | Earliest time as specified in the search command itself | -| `searchLatestTime` | string | Latest time as specified in the search command itself | +| `searchEarliestTime` | number | Earliest time as specified in the search command itself, as an epoch timestamp. Unlike earliestTime, which the job entry renders as an ISO string, this pair is documented as bare numbers \(e.g. 1308589800.000000000\). | +| `searchLatestTime` | number | Latest time as specified in the search command itself, as an epoch timestamp. Unlike latestTime, which the job entry renders as an ISO string, this pair is documented as bare numbers. | | `messages` | json | Errors and debug messages recorded for the job | ### Splunk Get Search Results @@ -197,56 +154,13 @@ Fetch the transformed results of a completed Splunk search job by search ID, wit | Parameter | Type | Description | | --------- | ---- | ----------- | -| `results` | json | Search result rows, each holding the fields the search produced | -| `resultCount` | number | Number of result rows returned | -| `preview` | boolean | Whether the results are previews | -| `initOffset` | number | Offset of the first returned row | -| `messages` | json | Messages returned with the response \(\[\{type, text\}\]\) | -| `sid` | string | Search ID of the job | -| `label` | string | Custom name of the search job | -| `dispatchState` | string | Current state of the search job | -| `doneProgress` | number | Approximate job progress between 0 and 1 | -| `isDone` | boolean | Whether the search has completed | -| `isFailed` | boolean | Whether the search failed | -| `isFinalized` | boolean | Whether the search was finalized | -| `isPaused` | boolean | Whether the search is paused | -| `isZombie` | boolean | Whether the search process died | -| `isSaved` | boolean | Whether the job artifacts are saved | -| `isSavedSearch` | boolean | Whether the job came from a saved search | -| `isRealTimeSearch` | boolean | Whether this is a real-time search | -| `eventCount` | number | Number of events returned | -| `eventAvailableCount` | number | Number of events available for export | -| `eventFieldCount` | number | Number of fields found in the results | -| `resultPreviewCount` | number | Number of rows in the latest preview | -| `scanCount` | number | Number of events scanned off disk | -| `runDuration` | number | Seconds the search took to complete | -| `priority` | number | Search priority between 0 and 10 | -| `earliestTime` | string | Earliest time bound of the job | -| `latestTime` | string | Latest time bound of the job | -| `searchEarliestTime` | string | Earliest time as specified in the search command | -| `searchLatestTime` | string | Latest time as specified in the search command | -| `savedSearches` | json | Saved searches \(\[\{name, id, author, updated, search, description, disabled, isScheduled, cronSchedule, alertType\}\]\) | -| `name` | string | Saved search name | -| `id` | string | Fully qualified REST URI of the resource | -| `author` | string | Owner of the saved search | -| `updated` | string | Last update timestamp | -| `search` | string | SPL the saved search runs | -| `qualifiedSearch` | string | Exact search string the scheduler runs | -| `description` | string | Saved search description | -| `disabled` | boolean | Whether the saved search is disabled | -| `isScheduled` | boolean | Whether the search runs on a schedule | -| `isVisible` | boolean | Whether the search is listed as visible | -| `cronSchedule` | string | Cron schedule for the search | -| `nextScheduledTime` | string | Next scheduled run time | -| `alertType` | string | Alert condition type | -| `dispatchEarliestTime` | string | Earliest time used when dispatching | -| `dispatchLatestTime` | string | Latest time used when dispatching | -| `alerts` | json | Saved searches with currently triggered alerts \(\[\{name, id, updated, triggeredAlertCount\}\]\) | -| `firedAlerts` | json | Triggered instances of an alert \(\[\{name, savedSearchName, alertType, severity, sid, triggerTime\}\]\) | -| `indexes` | json | Indexes configured on the instance \(\[\{name, datatype, disabled, totalEventCount, currentDBSizeMB, maxTotalDataSizeMB, minTime, maxTime\}\]\) | -| `apps` | json | Apps installed on the instance \(name, label, version, author, disabled\) | -| `total` | number | Total number of entries matching a list request, from the response paging envelope. Compare with offset to decide whether another page remains. | -| `offset` | number | Offset of the first entry in the returned page, from the paging envelope | +| `results` | array | Result rows. Each row holds the fields produced by the search. | +| `resultCount` | number | Number of result rows returned in this response | +| `preview` | boolean | Whether these are preview results from a still-running job | +| `initOffset` | number | Offset of the first returned row within the full result set | +| `messages` | array | Search messages returned alongside the results | +| ↳ `type` | string | Message severity | +| ↳ `text` | string | Message text | ### Splunk Cancel Search Job @@ -269,6 +183,9 @@ Cancel a running Splunk search job and delete its result cache. | Parameter | Type | Description | | --------- | ---- | ----------- | | `sid` | string | Search ID of the cancelled job | +| `messages` | array | Informational, warning, and error messages returned with the response | +| ↳ `type` | string | Message severity \(INFO, WARN, ERROR, DEBUG\) | +| ↳ `text` | string | Message text | ### Splunk List Saved Searches @@ -308,6 +225,8 @@ List saved searches and reports configured in Splunk, including their SPL, sched | ↳ `alertType` | string | Alert condition type \(e.g. always, custom, number of events\) | | ↳ `dispatchEarliestTime` | string | Earliest time bound used when the search is dispatched | | ↳ `dispatchLatestTime` | string | Latest time bound used when the search is dispatched | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | ### Splunk Get Saved Search @@ -400,6 +319,8 @@ List the saved searches with currently triggered (unexpired) Splunk alerts and h | ↳ `id` | string | Fully qualified REST URI of the entry | | ↳ `updated` | string | Last update timestamp | | ↳ `triggeredAlertCount` | number | Trigger count for this alert | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | ### Splunk Get Fired Alerts @@ -415,7 +336,7 @@ List the unexpired triggered instances of a Splunk alert by saved search name, i | `password` | string | No | Splunk password, used for basic authentication when no token is supplied | | `owner` | string | No | Namespace owner for /servicesNS requests \(e.g. admin, or nobody for app-shared objects\). Leave both this and the app empty to use the authenticated user context; set only one and the other becomes the - wildcard. | | `app` | string | No | Namespace app context for /servicesNS requests \(e.g. search\). Leave both this and the owner empty to use the authenticated user context; set only one and the other becomes the - wildcard. | -| `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search. | +| `name` | string | Yes | Name of the alerting saved search \(e.g. Errors in the last 24 hours\). Use - to return the fired alerts of every saved search — this endpoint documents "Request parameters: None", so there is no count or offset to bound that with. Name one saved search unless you really want all of them. | #### Output @@ -473,6 +394,8 @@ List the indexes configured on the Splunk instance with their size, event count, | ↳ `homePath` | string | Path to the hot and warm buckets | | ↳ `coldPath` | string | Path to the cold buckets | | ↳ `thawedPath` | string | Path to the thawed buckets | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | ### Splunk List Apps @@ -509,5 +432,7 @@ List the apps installed on the Splunk instance with their label, version, author | ↳ `configured` | boolean | Whether the custom app setup has been completed | | ↳ `checkForUpdates` | boolean | Whether Splunkbase is checked for app updates | | ↳ `stateChangeRequiresRestart` | boolean | Whether changing the app state requires a restart | +| `total` | number | Total number of entries matching the request, from the response paging envelope. Compare with offset to decide whether another page remains. | +| `offset` | number | Offset of the first entry in this page, echoed from the response paging envelope | diff --git a/apps/sim/app/api/tools/crowdstrike/query/falcon.ts b/apps/sim/app/api/tools/crowdstrike/query/falcon.ts index d5a5efaa167..fa21e3181b7 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/falcon.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/falcon.ts @@ -156,6 +156,21 @@ export function getFalconErrorMessage(data: unknown, fallback: string): string { ) } +/** + * Raised when the Falcon OAuth2 token exchange fails. Carries the Falcon status + * so the route can answer with the real cause (401 for bad credentials) instead + * of letting a credential problem fall through to a generic 500. + */ +export class CrowdStrikeAuthError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'CrowdStrikeAuthError' + this.status = status >= 400 && status <= 599 ? status : 502 + } +} + export async function getAccessToken(params: CrowdStrikeBaseParams): Promise { const baseUrl = getCloudBaseUrl(params.cloud) const response = await fetch(`${baseUrl}/oauth2/token`, { @@ -174,11 +189,14 @@ export async function getAccessToken(params: CrowdStrikeBaseParams): Promise null) if (!response.ok) { - throw new Error(getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike')) + throw new CrowdStrikeAuthError( + getFalconErrorMessage(data, 'Failed to authenticate with CrowdStrike'), + response.status + ) } if (!isRecordLike(data) || typeof data.access_token !== 'string') { - throw new Error('CrowdStrike authentication did not return an access token') + throw new CrowdStrikeAuthError('CrowdStrike authentication did not return an access token', 502) } return data.access_token diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.test.ts b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts index 634ff43f649..d4657e8690d 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.test.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts @@ -73,6 +73,45 @@ describe('CrowdStrike query route', () => { }) }) + it('surfaces a credential failure with the Falcon status, not a generic 500', async () => { + // getAccessToken runs before the operation dispatch, so a Falcon 401 used to + // fall through to the catch-all and reach the caller as a 500. + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: 401, message: 'access denied, invalid bearer token' }] }, 401) + ) + + const response = await POST( + createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'wrong-secret', + cloud: 'us-1', + limit: 1, + operation: 'crowdstrike_query_sensors', + }) + ) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ success: false, error: 'access denied, invalid bearer token' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('reports an unusable token response as a bad gateway rather than a 500', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ nothing: true })) + + const response = await POST( + createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + limit: 1, + operation: 'crowdstrike_query_sensors', + }) + ) + + expect(response.status).toBe(502) + }) + it('hydrates sensor details after querying sensor ids', async () => { fetchMock .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index ed502f38521..e943aa77904 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + CrowdStrikeAuthError, type CrowdStrikeCallResult, callCrowdStrike, getAccessToken, @@ -314,6 +315,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, output: result.output }) } catch (error) { const message = toError(error).message + + /** + * The token exchange runs before the operation dispatch, so without this a + * bad client ID or secret (Falcon 401) reaches the caller as a 500. + */ + if (error instanceof CrowdStrikeAuthError) { + logger.warn('CrowdStrike authentication failed', { error: message, status: error.status }) + return NextResponse.json({ success: false, error: message }, { status: error.status }) + } + logger.error('CrowdStrike request failed', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } diff --git a/apps/sim/app/api/tools/mssql/delete/route.ts b/apps/sim/app/api/tools/mssql/delete/route.ts index 7864b9e421a..44aab52a61b 100644 --- a/apps/sim/app/api/tools/mssql/delete/route.ts +++ b/apps/sim/app/api/tools/mssql/delete/route.ts @@ -6,7 +6,12 @@ import { mssqlDeleteContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildDeleteQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils' +import { + buildDeleteQuery, + createMSSQLConnection, + executeQuery, + toRowsResponseBody, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLDeleteAPI') @@ -28,19 +33,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` ) + /** + * Built before connecting so a rejected WHERE clause or a bad identifier + * costs no TLS+login round trip and answers 400 like the query and execute + * routes, rather than falling through to the catch-all as a 500. + */ + let built: { query: string; values: unknown[] } + try { + built = buildDeleteQuery(params.table, params.where) + } catch (error) { + const message = getErrorMessage(error, 'Invalid statement') + logger.warn(`[${requestId}] Delete statement rejected: ${message}`) + return NextResponse.json( + { error: `Microsoft SQL Server delete failed: ${message}` }, + { status: 400 } + ) + } + const pool = await createMSSQLConnection(params) try { - const { query, values } = buildDeleteQuery(params.table, params.where) - const result = await executeQuery(pool, query, values) + const result = await executeQuery(pool, built.query, built.values) logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`) - return NextResponse.json({ - message: `Data deleted successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody(result, `Data deleted successfully. ${result.rowCount} row(s) affected.`) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/execute/route.ts b/apps/sim/app/api/tools/mssql/execute/route.ts index 145a1dd8358..43bf87198b6 100644 --- a/apps/sim/app/api/tools/mssql/execute/route.ts +++ b/apps/sim/app/api/tools/mssql/execute/route.ts @@ -6,7 +6,12 @@ import { mssqlExecuteContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createMSSQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mssql/utils' +import { + createMSSQLConnection, + executeQuery, + toRowsResponseBody, + validateQuery, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLExecuteAPI') @@ -44,11 +49,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] T-SQL executed successfully, ${result.rowCount} row(s) affected`) - return NextResponse.json({ - message: `SQL executed successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody(result, `SQL executed successfully. ${result.rowCount} row(s) affected.`) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/insert/route.ts b/apps/sim/app/api/tools/mssql/insert/route.ts index 7ac25679902..6280ce22e77 100644 --- a/apps/sim/app/api/tools/mssql/insert/route.ts +++ b/apps/sim/app/api/tools/mssql/insert/route.ts @@ -6,7 +6,12 @@ import { mssqlInsertContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildInsertQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils' +import { + buildInsertQuery, + createMSSQLConnection, + executeQuery, + toRowsResponseBody, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLInsertAPI') @@ -28,19 +33,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` ) + /** + * Built before connecting so a bad identifier costs no TLS+login round trip + * and answers 400 like the update, delete, query, and execute routes, rather + * than falling through to the catch-all as a 500. + */ + let built: { query: string; values: unknown[] } + try { + built = buildInsertQuery(params.table, params.data) + } catch (error) { + const message = getErrorMessage(error, 'Invalid statement') + logger.warn(`[${requestId}] Insert statement rejected: ${message}`) + return NextResponse.json( + { error: `Microsoft SQL Server insert failed: ${message}` }, + { status: 400 } + ) + } + const pool = await createMSSQLConnection(params) try { - const { query, values } = buildInsertQuery(params.table, params.data) - const result = await executeQuery(pool, query, values) + const result = await executeQuery(pool, built.query, built.values) logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`) - return NextResponse.json({ - message: `Data inserted successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody( + result, + `Data inserted successfully. ${result.rowCount} row(s) affected.` + ) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/query/route.ts b/apps/sim/app/api/tools/mssql/query/route.ts index ab26602f155..ba0a1a1848b 100644 --- a/apps/sim/app/api/tools/mssql/query/route.ts +++ b/apps/sim/app/api/tools/mssql/query/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMSSQLConnection, executeQuery, + toRowsResponseBody, validateReadOnlyQuery, } from '@/app/api/tools/mssql/utils' @@ -48,11 +49,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`) - return NextResponse.json({ - message: `Query executed successfully. ${result.rowCount} row(s) returned.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody( + result, + `Query executed successfully. ${result.rowCount} row(s) returned.` + ) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/route-guards.test.ts b/apps/sim/app/api/tools/mssql/route-guards.test.ts new file mode 100644 index 00000000000..5d57ef4149b --- /dev/null +++ b/apps/sim/app/api/tools/mssql/route-guards.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + * + * The insert, update, and delete routes build their statement before opening a + * connection, so a rejected WHERE clause or a bad identifier costs no TLS+login + * round trip and answers 400 like the query and execute routes do. + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveHostAddresses, mockConnectionPool, mockQuery } = vi.hoisted(() => { + const query = vi.fn().mockResolvedValue({ recordset: [], rowsAffected: [1] }) + const pool = vi.fn(function ConnectionPool(this: Record) { + this.connect = vi.fn().mockResolvedValue(undefined) + this.close = vi.fn().mockResolvedValue(undefined) + this.request = () => ({ input: vi.fn(), query }) + }) + return { mockResolveHostAddresses: vi.fn(), mockConnectionPool: pool, mockQuery: query } +}) + +vi.mock('mssql', () => ({ + default: { ConnectionPool: mockConnectionPool }, + ConnectionPool: mockConnectionPool, +})) + +vi.mock('@sim/security/dns', () => ({ + resolveHostAddresses: mockResolveHostAddresses, + preferIpv4: (addresses: string[]) => addresses[0], +})) + +import { POST as DELETE_POST } from '@/app/api/tools/mssql/delete/route' +import { POST as INSERT_POST } from '@/app/api/tools/mssql/insert/route' +import { POST as UPDATE_POST } from '@/app/api/tools/mssql/update/route' + +const connection = { + host: 'db.example.com', + port: 1433, + database: 'app', + username: 'app', + password: 'secret', + encrypt: 'enabled', + trustServerCertificate: 'disabled', + connectionTimeout: 15000, +} + +describe('MSSQL insert, update, and delete guards run before connecting', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + mockResolveHostAddresses.mockResolvedValue({ addresses: ['93.184.216.34'], isPrivate: false }) + mockQuery.mockResolvedValue({ recordset: [], rowsAffected: [1] }) + }) + + it.each([ + ['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1 OR 1=1' }], + ['delete', DELETE_POST, { table: 'users', where: 'id = 1 OR 1=1' }], + ])( + 'answers 400 for a rejected WHERE clause on %s without connecting', + async (_op, handler, body) => { + const response = await handler(createMockRequest('POST', { ...connection, ...body })) + + expect(response.status).toBe(400) + expect(mockConnectionPool).not.toHaveBeenCalled() + } + ) + + it.each([ + ['insert', INSERT_POST, { table: 'users-table', data: { a: 1 } }], + ['insert column', INSERT_POST, { table: 'users', data: { 'bad-col': 1 } }], + ['update', UPDATE_POST, { table: 'users-table', data: { a: 1 }, where: 'id = 1' }], + ['delete', DELETE_POST, { table: 'users-table', where: 'id = 1' }], + ])('answers 400 for a bad identifier on %s without connecting', async (_op, handler, body) => { + const response = await handler(createMockRequest('POST', { ...connection, ...body })) + + expect(response.status).toBe(400) + expect(mockConnectionPool).not.toHaveBeenCalled() + }) + + it.each([ + ['insert', INSERT_POST, { table: 'users', data: { a: 1 } }], + ['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1' }], + ['delete', DELETE_POST, { table: 'users', where: 'id = 1' }], + ])('still runs an accepted %s statement', async (_op, handler, body) => { + const response = await handler(createMockRequest('POST', { ...connection, ...body })) + + expect(response.status).toBe(200) + expect(mockConnectionPool).toHaveBeenCalledTimes(1) + expect(mockQuery).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/tools/mssql/update/route.ts b/apps/sim/app/api/tools/mssql/update/route.ts index 7bd78844b2e..392aa3d5d1e 100644 --- a/apps/sim/app/api/tools/mssql/update/route.ts +++ b/apps/sim/app/api/tools/mssql/update/route.ts @@ -6,7 +6,12 @@ import { mssqlUpdateContract } from '@/lib/api/contracts/tools/databases/mssql' import { parseToolRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildUpdateQuery, createMSSQLConnection, executeQuery } from '@/app/api/tools/mssql/utils' +import { + buildUpdateQuery, + createMSSQLConnection, + executeQuery, + toRowsResponseBody, +} from '@/app/api/tools/mssql/utils' const logger = createLogger('MSSQLUpdateAPI') @@ -28,19 +33,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` ) + /** + * Built before connecting so a rejected WHERE clause or a bad identifier + * costs no TLS+login round trip and answers 400 like the query and execute + * routes, rather than falling through to the catch-all as a 500. + */ + let built: { query: string; values: unknown[] } + try { + built = buildUpdateQuery(params.table, params.data, params.where) + } catch (error) { + const message = getErrorMessage(error, 'Invalid statement') + logger.warn(`[${requestId}] Update statement rejected: ${message}`) + return NextResponse.json( + { error: `Microsoft SQL Server update failed: ${message}` }, + { status: 400 } + ) + } + const pool = await createMSSQLConnection(params) try { - const { query, values } = buildUpdateQuery(params.table, params.data, params.where) - const result = await executeQuery(pool, query, values) + const result = await executeQuery(pool, built.query, built.values) logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`) - return NextResponse.json({ - message: `Data updated successfully. ${result.rowCount} row(s) affected.`, - rows: result.rows, - rowCount: result.rowCount, - }) + return NextResponse.json( + toRowsResponseBody(result, `Data updated successfully. ${result.rowCount} row(s) affected.`) + ) } finally { await pool.close() } diff --git a/apps/sim/app/api/tools/mssql/utils.test.ts b/apps/sim/app/api/tools/mssql/utils.test.ts index c29ff99f63f..101bf210dea 100644 --- a/apps/sim/app/api/tools/mssql/utils.test.ts +++ b/apps/sim/app/api/tools/mssql/utils.test.ts @@ -38,8 +38,10 @@ import { buildInsertQuery, buildUpdateQuery, createMSSQLConnection, + executeIntrospect, executeQuery, type MSSQLConnectionConfig, + toRowsResponseBody, validateQuery, validateReadOnlyQuery, } from '@/app/api/tools/mssql/utils' @@ -388,3 +390,327 @@ describe('createMSSQLConnection DNS pinning', () => { expect(config.options.trustServerCertificate).toBe(true) }) }) + +describe('read-only screens cover the rest of the session and transaction family', () => { + /** + * Each is a valid semicolon-less second statement, and the file's stated rule + * is that a second statement is rejected structurally rather than by what it + * happens to do. + */ + it.each([ + ['SAVE TRANSACTION', 'SELECT 1 SAVE TRANSACTION sp1'], + ['SAVE TRAN', 'SELECT 1 SAVE TRAN sp1'], + ['OPEN SYMMETRIC KEY', 'SELECT 1 OPEN SYMMETRIC KEY k DECRYPTION BY CERTIFICATE c'], + ['OPEN MASTER KEY', "SELECT 1 OPEN MASTER KEY DECRYPTION BY PASSWORD = 'p'"], + ['CLOSE ALL SYMMETRIC KEYS', 'SELECT 1 CLOSE ALL SYMMETRIC KEYS'], + ['CLOSE MASTER KEY', 'SELECT 1 CLOSE MASTER KEY'], + ['DEALLOCATE', 'SELECT 1 DEALLOCATE cur'], + ['ADD SIGNATURE', 'SELECT 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c'], + ['RAISERROR WITH LOG', "SELECT 1 RAISERROR ('boom', 16, 1) WITH LOG"], + ])('rejects %s in the Query operation', (_label, query) => { + expect(validateReadOnlyQuery(query).isValid).toBe(false) + }) + + it.each([ + ['SAVE TRANSACTION', 'id = 1 SAVE TRANSACTION sp1'], + ['OPEN SYMMETRIC KEY', 'id = 1 OPEN SYMMETRIC KEY k DECRYPTION BY CERTIFICATE c'], + ['CLOSE ALL SYMMETRIC KEYS', 'id = 1 CLOSE ALL SYMMETRIC KEYS'], + ['DEALLOCATE', 'id = 1 DEALLOCATE cur'], + ['ADD SIGNATURE', 'id = 1 ADD SIGNATURE TO dbo.p BY CERTIFICATE c'], + ['RAISERROR WITH LOG', "id = 1 RAISERROR ('boom', 16, 1) WITH LOG"], + ])('rejects %s in an update or delete WHERE clause', (_label, where) => { + expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow() + expect(() => buildDeleteQuery('t', where)).toThrow() + }) + + /** + * The over-screening guard. `open`, `close`, `save`, and `add` are ordinary + * column names (a price table has all four), so a bare-word screen would make + * the plain SELECTs this operation exists to run un-runnable. + */ + it('still accepts ordinary identifiers that start with a screened phrase word', () => { + const allowed = [ + 'SELECT open, close, high, low FROM dbo.prices', + 'SELECT close FROM dbo.prices WHERE open > 10', + 'SELECT save_id, add_on, open_date, close_date FROM dbo.orders', + 'SELECT o.open, o.close FROM dbo.ohlc o ORDER BY o.open DESC', + ] + + for (const query of allowed) { + expect(validateReadOnlyQuery(query)).toEqual({ isValid: true }) + } + + expect(() => buildUpdateQuery('prices', { close: 2 }, 'open > 10')).not.toThrow() + expect(() => buildDeleteQuery('prices', 'close < 1 AND open_date > 0')).not.toThrow() + }) +}) + +describe('read-only screens cover RENAME and the Service Broker statement family', () => { + /** + * `RENAME` is documented T-SQL DDL for Azure Synapse dedicated SQL pools and + * Analytics Platform System, both reachable over TDS with the connection + * fields this block exposes — so a schema change was passing an operation + * advertised as read-only. + */ + it.each([ + ['RENAME OBJECT', 'SELECT 1 RENAME OBJECT dbo.Customer TO Customer1'], + ['RENAME OBJECT COLUMN', 'SELECT 1 RENAME OBJECT dbo.t COLUMN c1 TO c2'], + ['RENAME DATABASE', 'SELECT 1 RENAME DATABASE db1 TO db2'], + ['RECEIVE', 'SELECT 1 RECEIVE TOP(1) * FROM dbo.MyQueue'], + ['END CONVERSATION', "SELECT 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"], + [ + 'MOVE CONVERSATION', + "SELECT 1 MOVE CONVERSATION '00000000-0000-0000-0000-000000000000' TO '00000000-0000-0000-0000-000000000001'", + ], + ['GET CONVERSATION GROUP', 'SELECT 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'], + [ + 'SEND ON CONVERSATION', + "SELECT 1 SEND ON CONVERSATION '00000000-0000-0000-0000-000000000000' MESSAGE TYPE [t] ('x')", + ], + ])('rejects %s in the Query operation', (_label, query) => { + expect(validateReadOnlyQuery(query).isValid).toBe(false) + }) + + it.each([ + ['RENAME OBJECT', 'id = 1 RENAME OBJECT dbo.t TO t2'], + ['RECEIVE', 'id = 1 RECEIVE TOP(1) * FROM dbo.MyQueue'], + ['END CONVERSATION', "id = 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"], + ['GET CONVERSATION GROUP', 'id = 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'], + ])('rejects %s in an update or delete WHERE clause', (_label, where) => { + expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow() + expect(() => buildDeleteQuery('t', where)).toThrow() + }) + + /** + * The over-screening guard. `END` closes every `CASE`, and `rename`/`receive` + * are the stems of ordinary column names, so neither addition may cost the + * plain SELECTs this operation exists to run. + */ + it('still accepts CASE … END and ordinary identifiers built on the new words', () => { + const allowed = [ + "SELECT CASE WHEN status = 1 THEN 'on' ELSE 'off' END FROM dbo.jobs", + "SELECT CASE WHEN a = 1 THEN 'x' END AS conversation_state FROM dbo.t", + 'SELECT renamed_at, rename_log, received_at, receive_queue FROM dbo.audit', + 'SELECT conversation_id, get_flag, move_order, send_at, end_date FROM dbo.t', + ] + + for (const query of allowed) { + expect(validateReadOnlyQuery(query)).toEqual({ isValid: true }) + } + + expect(() => buildUpdateQuery('audit', { a: 1 }, 'renamed_at > 0')).not.toThrow() + expect(() => buildDeleteQuery('audit', 'received_at > 0 AND conversation_id = 3')).not.toThrow() + }) +}) + +describe('executeQuery result caps', () => { + function makeCapPool(recordset: unknown[]) { + return { + request: () => ({ + input: vi.fn(), + query: vi.fn().mockResolvedValue({ recordset, rowsAffected: [0] }), + }), + } as never + } + + it('caps the recordset at the row ceiling and says so', async () => { + const result = await executeQuery( + makeCapPool(Array.from({ length: 10_001 }, (_, i) => ({ i }))), + 'SELECT 1' + ) + + expect(result.rows).toHaveLength(10_000) + expect(result.rowCount).toBe(10_000) + expect(result.truncated).toBe(true) + expect(result.truncationReason).toMatch(/OFFSET/) + }) + + it('caps on bytes even when the row count is small', async () => { + // 20 rows of ~1MB each: well under the row ceiling, well over the byte one. + const fat = Array.from({ length: 20 }, () => ({ blob: 'x'.repeat(1024 * 1024) })) + const result = await executeQuery(makeCapPool(fat), 'SELECT 1') + + expect(result.rows.length).toBeLessThan(20) + expect(result.truncated).toBe(true) + }) + + it('leaves an ordinary result untouched', async () => { + const rows = [{ id: 1 }, { id: 2 }] + const result = await executeQuery(makeCapPool(rows), 'SELECT 1') + + expect(result.rows).toEqual(rows) + expect(result.truncated).toBeUndefined() + expect(result.truncationReason).toBeUndefined() + }) + + it('never serializes past the byte ceiling', async () => { + const fat = Array.from({ length: 20 }, () => ({ blob: 'x'.repeat(1024 * 1024) })) + const result = await executeQuery(makeCapPool(fat), 'SELECT 1') + + expect(JSON.stringify(result.rows).length).toBeLessThanOrEqual(10 * 1024 * 1024) + }) + + it('drops a lone row that is larger than the byte ceiling rather than admitting it', async () => { + const oversized = [{ blob: 'x'.repeat(11 * 1024 * 1024) }] + const result = await executeQuery(makeCapPool(oversized), 'SELECT 1') + + expect(result.rows).toEqual([]) + expect(result.truncated).toBe(true) + expect(result.truncationReason).toMatch(/exceeds the 10 MB response ceiling/) + }) + + /** + * `String.length` counts UTF-16 code units and the response is emitted as + * UTF-8, so a CJK recordset costs three bytes for every unit the old + * accounting charged one for. Measured with `length` these rows fit; measured + * as the bytes that actually go on the wire they are ~3x over. + */ + it('bounds a multibyte recordset by UTF-8 bytes, not UTF-16 code units', async () => { + const cjk = Array.from({ length: 20 }, () => ({ blob: '世'.repeat(1024 * 1024) })) + const result = await executeQuery(makeCapPool(cjk), 'SELECT 1') + + expect(Buffer.byteLength(JSON.stringify(result.rows), 'utf8')).toBeLessThanOrEqual( + 10 * 1024 * 1024 + ) + expect(result.rows.length).toBeGreaterThan(0) + expect(result.truncated).toBe(true) + }) + + /** Emoji are 4 UTF-8 bytes across 2 surrogate code units — a 2:1 undercount. */ + it('bounds an astral-plane recordset by UTF-8 bytes', async () => { + const emoji = Array.from({ length: 20 }, () => ({ blob: '😀'.repeat(1024 * 1024) })) + const result = await executeQuery(makeCapPool(emoji), 'SELECT 1') + + expect(Buffer.byteLength(JSON.stringify(result.rows), 'utf8')).toBeLessThanOrEqual( + 10 * 1024 * 1024 + ) + expect(result.truncated).toBe(true) + }) + + /** + * Rows sized to divide the ceiling exactly, so an accounting that ignores the + * array's commas and the fields around it lands precisely on the limit and the + * body it emits is over by the punctuation and the envelope. + */ + it('keeps the emitted body inside the ceiling once array and envelope overhead is counted', async () => { + const rowPayload = 'x'.repeat(2048 - '{"blob":""}'.length) + const packed = Array.from({ length: 6000 }, () => ({ blob: rowPayload })) + const result = await executeQuery(makeCapPool(packed), 'SELECT 1') + + const body = toRowsResponseBody(result, 'Query executed successfully. rows returned.') + + expect(result.truncated).toBe(true) + expect(Buffer.byteLength(JSON.stringify(body), 'utf8')).toBeLessThanOrEqual(10 * 1024 * 1024) + }) +}) + +describe('toRowsResponseBody truncation disclosure', () => { + it('discloses a truncated result in both the message and machine-readable fields', () => { + const body = toRowsResponseBody( + { + rows: [{ id: 1 }], + rowCount: 1, + truncated: true, + truncationReason: 'Result truncated to 1 row(s): page with OFFSET ... FETCH NEXT.', + }, + 'Query executed successfully. 1 row(s) returned.' + ) + + expect(body.truncated).toBe(true) + expect(body.truncationReason).toMatch(/OFFSET/) + expect(body.message).toBe( + 'Query executed successfully. 1 row(s) returned. Result truncated to 1 row(s): page with OFFSET ... FETCH NEXT.' + ) + }) + + it('leaves a complete result free of truncation fields', () => { + const body = toRowsResponseBody( + { rows: [{ id: 1 }], rowCount: 1 }, + 'Query executed successfully. 1 row(s) returned.' + ) + + expect(body.message).toBe('Query executed successfully. 1 row(s) returned.') + expect(body).not.toHaveProperty('truncated') + expect(body).not.toHaveProperty('truncationReason') + }) +}) + +describe('executeIntrospect issues a fixed number of queries', () => { + const schemas = [{ SCHEMA_NAME: 'dbo' }] + const introspectTables = Array.from({ length: 50 }, (_, i) => ({ + TABLE_NAME: `t${i}`, + TABLE_SCHEMA: 'dbo', + })) + const introspectColumns = introspectTables.flatMap((t) => [ + { + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'id', + DATA_TYPE: 'int', + IS_NULLABLE: 'NO', + COLUMN_DEFAULT: null, + }, + { + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'owner_id', + DATA_TYPE: 'int', + IS_NULLABLE: 'YES', + COLUMN_DEFAULT: null, + }, + ]) + const introspectPks = introspectTables.map((t) => ({ + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'id', + })) + const introspectFks = introspectTables.map((t) => ({ + TABLE_NAME: t.TABLE_NAME, + COLUMN_NAME: 'owner_id', + REFERENCED_TABLE_SCHEMA: 'dbo', + REFERENCED_TABLE_NAME: 'owners', + REFERENCED_COLUMN_NAME: 'id', + })) + const introspectIndexes = introspectTables.map((t) => ({ + TABLE_NAME: t.TABLE_NAME, + INDEX_NAME: `ix_${t.TABLE_NAME}_owner`, + COLUMN_NAME: 'owner_id', + IS_UNIQUE: 0, + })) + + function makeIntrospectPool() { + const query = vi.fn(async (text: string) => { + if (text.includes('FROM sys.schemas s')) return { recordset: schemas } + if (text.includes('INFORMATION_SCHEMA.TABLES')) return { recordset: introspectTables } + if (text.includes('INFORMATION_SCHEMA.COLUMNS')) return { recordset: introspectColumns } + if (text.includes('PRIMARY KEY')) return { recordset: introspectPks } + if (text.includes('sys.foreign_keys')) return { recordset: introspectFks } + if (text.includes('sys.index_columns')) return { recordset: introspectIndexes } + throw new Error(`unexpected query: ${text}`) + }) + return { pool: { request: () => ({ input: vi.fn().mockReturnThis(), query }) } as never, query } + } + + it('does not scale its round trips with the table count', async () => { + // Previously 4 queries per table plus 2: 50 tables meant 202 sequential + // round trips, each under its own request timeout. + const { pool, query } = makeIntrospectPool() + + const result = await executeIntrospect(pool, 'dbo') + + expect(result.tables).toHaveLength(50) + expect(query.mock.calls.length).toBeLessThanOrEqual(6) + }) + + it('still attributes columns, keys, and indexes to the right table', async () => { + const { pool } = makeIntrospectPool() + + const result = await executeIntrospect(pool, 'dbo') + const table = result.tables.find((t) => t.name === 't7')! + + expect(table.schema).toBe('dbo') + expect(table.columns.map((c) => c.name)).toEqual(['id', 'owner_id']) + expect(table.primaryKey).toEqual(['id']) + expect(table.columns[0].isPrimaryKey).toBe(true) + expect(table.columns[1].isForeignKey).toBe(true) + expect(table.columns[1].references).toEqual({ schema: 'dbo', table: 'owners', column: 'id' }) + expect(table.indexes).toEqual([{ name: 'ix_t7_owner', columns: ['owner_id'], unique: false }]) + }) +}) diff --git a/apps/sim/app/api/tools/mssql/utils.ts b/apps/sim/app/api/tools/mssql/utils.ts index e8cd95d0961..8cadafcc9cb 100644 --- a/apps/sim/app/api/tools/mssql/utils.ts +++ b/apps/sim/app/api/tools/mssql/utils.ts @@ -124,6 +124,10 @@ export async function createMSSQLConnection( export interface MSSQLQueryResult { rows: unknown[] rowCount: number + /** Set when the recordset hit a row or byte ceiling and rows were dropped. */ + truncated?: boolean + /** Human-readable explanation of the ceiling that was hit. */ + truncationReason?: string } /** @@ -145,6 +149,87 @@ function toBindableValue(value: unknown): unknown { return JSON.stringify(value) } +/** + * Ceilings on what a single statement may materialize into the response. + * + * The driver buffers the whole recordset before `request.query` resolves, and + * the route then serializes it into a JSON body, so an unbounded `SELECT` over a + * large table is held in memory twice. A caller who wants more pages it with + * `OFFSET ... FETCH NEXT`. The byte ceiling exists because row count alone does + * not bound size — 1,000 rows of `nvarchar(max)` is not a small result. + */ +const MSSQL_MAX_RESULT_ROWS = 10_000 +const MSSQL_MAX_RESULT_BYTES = 10 * 1024 * 1024 + +/** + * Bytes held back from {@link MSSQL_MAX_RESULT_BYTES} for the part of the + * response body that is not a row. + * + * {@link toRowsResponseBody} wraps `rows` in `message`, `rowCount`, and — when + * the recordset was capped — `truncated` and `truncationReason`, none of which + * the per-row accounting can see. Those are a few hundred bytes at their + * longest (the truncation prose is the bulk of it), so the reserve is set an + * order of magnitude above the worst case and costs 0.04% of the ceiling. The + * alternative, serializing the assembled body to check it, would re-serialize + * the whole recordset a second time for no useful precision. + */ +const MSSQL_RESPONSE_ENVELOPE_BYTES = 4096 + +/** What the serialized `rows` array itself may occupy. */ +const MSSQL_MAX_ROWS_BYTES = MSSQL_MAX_RESULT_BYTES - MSSQL_RESPONSE_ENVELOPE_BYTES + +/** + * Truncates a recordset to the row and byte ceilings. + * + * Measures each row with `JSON.stringify` because that is what the route will do + * anyway, so the number bounds the response the caller actually receives rather + * than an in-memory estimate that does not correspond to it. Each row is + * serialized exactly once and its cost accumulated, rather than re-serializing + * the growing array per row, which would be quadratic on a large recordset. + * + * The size is `Buffer.byteLength(..., 'utf8')`, not `String.length`. `length` + * counts UTF-16 code units while `NextResponse.json` emits UTF-8, and every + * character above U+007F costs more bytes than code units — worst case 3:1, for + * the U+0800–U+FFFF range that holds CJK, so a recordset of Chinese text passed + * a 10 MB `length` budget while serializing to nearly 30 MB. (Astral characters + * such as emoji are only 2:1: 4 bytes across 2 surrogate code units.) + * + * The array's own punctuation is counted too — one byte per row covers the + * opening `[` for the first row and the separating `,` for each one after it, + * with the leading byte standing in for the closing `]` — and + * {@link MSSQL_RESPONSE_ENVELOPE_BYTES} covers the fields around it. Without + * both, a result packed exactly to the ceiling still emitted a body over it. + * + * A row is admitted only when it still fits, so a single row larger than the + * byte ceiling is dropped rather than admitted as a lone exception — otherwise + * `SELECT` of one `nvarchar(max)` value would serialize an unbounded body and + * the ceiling would bound everything except the case it exists for. The drop is + * disclosed through {@link MSSQLQueryResult.truncationReason}, so an empty + * recordset is never mistaken for an empty table. + */ +function capRecordset(rows: unknown[]): { rows: unknown[]; truncated: boolean } { + if (rows.length === 0) return { rows, truncated: false } + + const capped: unknown[] = [] + /** The closing `]`; each row below pays for its own `[` or `,`. */ + let bytes = 1 + + for (const row of rows) { + if (capped.length >= MSSQL_MAX_RESULT_ROWS) break + const serialized = JSON.stringify(row) + /** + * `JSON.stringify` answers `undefined` for a value it cannot represent, but + * an array element in that position serializes as the four bytes of `null`. + */ + const rowBytes = serialized === undefined ? 4 : Buffer.byteLength(serialized, 'utf8') + if (bytes + rowBytes + 1 > MSSQL_MAX_ROWS_BYTES) break + bytes += rowBytes + 1 + capped.push(row) + } + + return { rows: capped, truncated: capped.length < rows.length } +} + /** * Runs a statement with positional values bound as `@param1`, `@param2`, … . * @@ -164,7 +249,7 @@ export async function executeQuery( }) const result = await request.query(query) - const rows: unknown[] = result.recordset ?? [] + const { rows, truncated } = capRecordset(result.recordset ?? []) const affected = (result.rowsAffected ?? []).reduce( (total: number, count: number) => total + count, 0 @@ -173,6 +258,34 @@ export async function executeQuery( return { rows, rowCount: rows.length > 0 ? rows.length : affected, + ...(truncated && { + truncated: true, + truncationReason: + rows.length === 0 + ? `No rows returned: the first row alone exceeds the ${MSSQL_MAX_RESULT_BYTES / (1024 * 1024)} MB response ceiling. Select fewer columns, or slice large values with SUBSTRING.` + : `Result truncated to ${rows.length} row(s): a single statement returns at most ${MSSQL_MAX_RESULT_ROWS} rows or ${MSSQL_MAX_RESULT_BYTES / (1024 * 1024)} MB. Page with OFFSET ... FETCH NEXT to read the rest.`, + }), + } +} + +/** + * Builds the success body every statement route returns. + * + * A truncated recordset is disclosed twice on purpose: folded into `message`, so + * an agent that reads only the status line still learns rows were dropped, and + * as `truncated`/`truncationReason`, so a caller can branch on it without + * parsing prose. Without this the route reported a capped result as a complete + * one and paging looked unnecessary. + */ +export function toRowsResponseBody(result: MSSQLQueryResult, message: string) { + return { + message: result.truncationReason ? `${message} ${result.truncationReason}` : message, + rows: result.rows, + rowCount: result.rowCount, + ...(result.truncated && { + truncated: true, + truncationReason: result.truncationReason, + }), } } @@ -187,7 +300,11 @@ export async function executeQuery( * `DISABLE`/`ENABLE` are here because `SELECT 1 DISABLE TRIGGER dbo.audit ON * dbo.users` is a valid semicolon-less batch that turns auditing off, and * `SET`/`BEGIN`/`COMMIT`/`ROLLBACK` because session and transaction state are - * changed the same way (`SET IDENTITY_INSERT`, `SET ANSI_NULLS`). + * changed the same way (`SET IDENTITY_INSERT`, `SET ANSI_NULLS`). The rest of + * that family — `SAVE TRANSACTION`, the symmetric/master key statements, + * `ADD SIGNATURE`, and `RAISERROR ... WITH LOG` — opens with a word that is also + * an ordinary identifier, so it is screened as a two-token phrase in + * {@link MSSQL_STATEMENT_PHRASES} instead. * * The text statements `UPDATETEXT`, `WRITETEXT`, and `READTEXT` are listed in * their own right rather than left to `update`: there is no word boundary after @@ -197,6 +314,20 @@ export async function executeQuery( * but it introduces a second statement in exactly the same semicolon-less way, * which is what this list exists to reject. * + * `RENAME` is documented T-SQL DDL — it applies to Azure Synapse Analytics + * dedicated SQL pools and Analytics Platform System, both of which speak TDS on + * port 1433 and are reachable with exactly the connection fields this block + * exposes. `SELECT 1 RENAME OBJECT dbo.Customer TO Customer1` is a valid + * semicolon-less batch that changes schema through an operation advertised as + * read-only, and `RENAME DATABASE` and `RENAME OBJECT … COLUMN … TO …` reach it + * the same way. + * + * `RECEIVE` is the Service Broker read that *removes* the messages it returns, + * so it is a write in everything but name. Its siblings — `END`/`MOVE`/`GET` + * `CONVERSATION` and `SEND ON CONVERSATION` — open with words that are ordinary + * identifiers (`END` closes every `CASE`), so they are screened as phrases in + * {@link MSSQL_STATEMENT_PHRASES} instead. + * * `FETCH` is deliberately **absent**: `OFFSET … FETCH NEXT` is the standard * T-SQL paging clause, so screening it would reject the ordinary paged SELECT * this operation exists to run. Word boundaries keep the additions off ordinary @@ -204,7 +335,50 @@ export async function executeQuery( * @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements */ const MSSQL_STATEMENT_KEYWORDS = - /\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into)\b/i + /\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|rename|receive|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into|deallocate)\b/i + +/** + * The remaining session, transaction, cursor, and key-management statements, + * every one of which is a valid semicolon-less second statement the single-word + * list above cannot carry. + * + * Each is matched as a **two-token** phrase rather than a bare word, because the + * leading words are ordinary identifiers: `open` and `close` are columns in any + * price table, `save` and `add` are common verbs, and `END` closes every `CASE`. + * Screening those bare would reject the plain SELECTs this operation exists to + * run. `DEALLOCATE` is the one exception and lives in the word list above — it + * has no ordinary-identifier reading. + * + * Most of these write neither table data nor schema, which is why they were + * missed; they are screened because the file's stated rule is that a second + * statement is rejected structurally, not by what it happens to do. + * `RAISERROR ... WITH LOG` writes to the error log and the Windows application + * log, so it is not inert. The Service Broker conversation statements are not + * inert either: `END CONVERSATION ... WITH CLEANUP` drops every message in a + * conversation, `MOVE CONVERSATION` reassigns it, and `SEND ON CONVERSATION` + * enqueues a message — and the handles they need are enumerable through this + * same path, because the catalog screen applies only to WHERE clauses. + * @see https://learn.microsoft.com/en-us/sql/t-sql/statements/end-conversation-transact-sql + * @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements + */ +const MSSQL_STATEMENT_PHRASES: readonly RegExp[] = [ + /\bsave\s+tran(?:saction)?\b/i, + /\bopen\s+(?:symmetric|master)\s+key\b/i, + /\bclose\s+(?:all\s+symmetric\s+keys|master\s+key|symmetric\s+key)\b/i, + /\badd\s+signature\b/i, + /\braiserror[\s\S]*?\bwith\s+log\b/i, + /\b(?:end|move|get)\s+conversation\b/i, + /\bsend\s+on\s+conversation\b/i, +] + +/** Matches the first screened statement phrase, or `null`. */ +function matchStatementPhrase(masked: string): string | null { + for (const pattern of MSSQL_STATEMENT_PHRASES) { + const match = pattern.exec(masked) + if (match) return match[0] + } + return null +} /** Extended, OLE-automation, and system stored procedures, called with or without `EXEC`. */ const MSSQL_PROCEDURE_PATTERN = /\b(?:xp_|sp_)\w+/i @@ -355,11 +529,14 @@ export function validateReadOnlyQuery(query: string): { isValid: boolean; error? } } - const disallowed = MSSQL_STATEMENT_KEYWORDS.exec(masked) ?? MSSQL_PROCEDURE_PATTERN.exec(masked) + const disallowed = + MSSQL_STATEMENT_KEYWORDS.exec(masked)?.[0] ?? + MSSQL_PROCEDURE_PATTERN.exec(masked)?.[0] ?? + matchStatementPhrase(masked) if (disallowed) { return { isValid: false, - error: `The Query operation cannot run ${disallowed[0].toUpperCase()}. Use the Execute Raw SQL operation for statements that modify data, schema, or server state.`, + error: `The Query operation cannot run ${disallowed.toUpperCase()}. Use the Execute Raw SQL operation for statements that modify data, schema, or server state.`, } } @@ -519,6 +696,7 @@ function validateWhereClause(where: string): void { const masked = maskSqlStringLiterals(where) if ( MSSQL_STATEMENT_KEYWORDS.test(masked) || + matchStatementPhrase(masked) !== null || MSSQL_PROCEDURE_PATTERN.test(masked) || MSSQL_WHERE_SELECT.test(masked) || MSSQL_WHERE_CONSTANT_TAUTOLOGY.some((pattern) => pattern.test(masked)) || @@ -592,6 +770,7 @@ interface TableRow { } interface ColumnRow { + TABLE_NAME: string COLUMN_NAME: string DATA_TYPE: string IS_NULLABLE: string @@ -599,10 +778,12 @@ interface ColumnRow { } interface KeyColumnRow { + TABLE_NAME: string COLUMN_NAME: string } interface ForeignKeyRow { + TABLE_NAME: string COLUMN_NAME: string REFERENCED_TABLE_SCHEMA: string REFERENCED_TABLE_NAME: string @@ -610,6 +791,7 @@ interface ForeignKeyRow { } interface IndexRow { + TABLE_NAME: string INDEX_NAME: string COLUMN_NAME: string IS_UNIQUE: boolean | number @@ -654,75 +836,142 @@ export async function executeIntrospect( ORDER BY TABLE_NAME` ) + const tableRows = tablesResult.recordset as TableRow[] + if (tableRows.length === 0) return { tables: [], schemas } + + /** + * The column, primary key, foreign key, and index reads below are filtered by + * schema and grouped in memory, rather than run once per table. Per-table they + * were four round trips each — a 500-table schema meant ~2,000 sequential + * queries, every one under its own connection timeout. + */ + const columnsResult = await pool + .request() + .input('schema', schemaName) + .query( + `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = @schema + ORDER BY TABLE_NAME, ORDINAL_POSITION` + ) + + const pkResult = await pool + .request() + .input('schema', schemaName) + .query( + `SELECT tc.TABLE_NAME, kcu.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND tc.TABLE_SCHEMA = @schema + ORDER BY tc.TABLE_NAME, kcu.ORDINAL_POSITION` + ) + + const fkResult = await pool + .request() + .input('schema', schemaName) + .query( + /** + * Resolved through the catalog views rather than + * `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, which reaches the + * referenced side by joining `TABLE_CONSTRAINTS` — a view that returns + * "one row for each table constraint" and so has no row at all when a + * foreign key references a unique *index*, silently dropping the key. + * The catalog views resolve the referenced table and column by ID. + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-foreign-key-columns-transact-sql + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-information-schema-views/table-constraints-transact-sql + */ + `SELECT + pt.name AS TABLE_NAME, + pc.name AS COLUMN_NAME, + rs.name AS REFERENCED_TABLE_SCHEMA, + rt.name AS REFERENCED_TABLE_NAME, + rc.name AS REFERENCED_COLUMN_NAME + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id + JOIN sys.tables pt ON pt.object_id = fk.parent_object_id + JOIN sys.schemas ps ON ps.schema_id = pt.schema_id + JOIN sys.columns pc + ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id + JOIN sys.tables rt ON rt.object_id = fkc.referenced_object_id + JOIN sys.schemas rs ON rs.schema_id = rt.schema_id + JOIN sys.columns rc + ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id + WHERE ps.name = @schema + ORDER BY pt.name, fk.name, fkc.constraint_column_id` + ) + + const indexResult = await pool + .request() + .input('schema', schemaName) + .query( + /** + * `key_ordinal > 0` restricts the result to key columns: it is the + * "ordinal (1-based) within set of key-columns", and `0` marks INCLUDEd + * non-key columns, partitioning columns, **and every column of an XML, + * spatial, columnstore, or JSON index**. The partitioning columns are + * why `is_included_column` alone is not enough — those report `0` for it + * too. The index families are the cost of the filter: they contribute no + * key column, so they are absent from the result rather than listed with + * an empty column set. Rowstore keys, which is what a query planner + * reader is after, are reported in full. + * + * `is_hypothetical = 0` drops the statistics-only indexes the Database + * Engine Tuning Advisor leaves behind ("can't be used directly as a data + * access path"), and `is_disabled = 0` drops indexes that exist but are + * not maintained. Reporting either as a live index misleads. + * + * `is_primary_key = 0` keeps the primary key out, since `primaryKey` + * carries it already. A UNIQUE *constraint* is deliberately left in: it + * is a unique index and nothing else in the result reports it. + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-index-columns-transact-sql + * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-indexes-transact-sql + */ + `SELECT t.name AS TABLE_NAME, i.name AS INDEX_NAME, c.name AS COLUMN_NAME, + i.is_unique AS IS_UNIQUE + FROM sys.indexes i + JOIN sys.index_columns ic + ON i.object_id = ic.object_id AND i.index_id = ic.index_id + JOIN sys.columns c + ON ic.object_id = c.object_id AND ic.column_id = c.column_id + JOIN sys.tables t ON i.object_id = t.object_id + JOIN sys.schemas s ON t.schema_id = s.schema_id + WHERE s.name = @schema + AND i.is_primary_key = 0 + AND i.is_hypothetical = 0 + AND i.is_disabled = 0 + AND i.name IS NOT NULL + AND ic.key_ordinal > 0 + ORDER BY t.name, i.name, ic.key_ordinal` + ) + + /** Groups rows by their `TABLE_NAME`, preserving each group's server order. */ + function groupByTable(rows: TRow[]): Map { + const grouped = new Map() + for (const row of rows) { + const existing = grouped.get(row.TABLE_NAME) + if (existing) existing.push(row) + else grouped.set(row.TABLE_NAME, [row]) + } + return grouped + } + + const columnsByTable = groupByTable(columnsResult.recordset as ColumnRow[]) + const pkByTable = groupByTable(pkResult.recordset as KeyColumnRow[]) + const fkByTable = groupByTable(fkResult.recordset as ForeignKeyRow[]) + const indexRowsByTable = groupByTable(indexResult.recordset as IndexRow[]) + const tables: MSSQLIntrospectionResult['tables'] = [] - for (const tableRow of tablesResult.recordset as TableRow[]) { + for (const tableRow of tableRows) { const tableName = tableRow.TABLE_NAME const tableSchema = tableRow.TABLE_SCHEMA - const columnsResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - `SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table - ORDER BY ORDINAL_POSITION` - ) - - const pkResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - `SELECT kcu.COLUMN_NAME - FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc - JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu - ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME - AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA - WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.TABLE_SCHEMA = @schema - AND tc.TABLE_NAME = @table - ORDER BY kcu.ORDINAL_POSITION` - ) - const primaryKeyColumns = pkResult.recordset.map((row: KeyColumnRow) => row.COLUMN_NAME) - - const fkResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - /** - * Resolved through the catalog views rather than - * `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, which reaches the - * referenced side by joining `TABLE_CONSTRAINTS` — a view that returns - * "one row for each table constraint" and so has no row at all when a - * foreign key references a unique *index*, silently dropping the key. - * The catalog views resolve the referenced table and column by ID. - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-foreign-key-columns-transact-sql - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-information-schema-views/table-constraints-transact-sql - */ - `SELECT - pc.name AS COLUMN_NAME, - rs.name AS REFERENCED_TABLE_SCHEMA, - rt.name AS REFERENCED_TABLE_NAME, - rc.name AS REFERENCED_COLUMN_NAME - FROM sys.foreign_keys fk - JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id - JOIN sys.tables pt ON pt.object_id = fk.parent_object_id - JOIN sys.schemas ps ON ps.schema_id = pt.schema_id - JOIN sys.columns pc - ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id - JOIN sys.tables rt ON rt.object_id = fkc.referenced_object_id - JOIN sys.schemas rs ON rs.schema_id = rt.schema_id - JOIN sys.columns rc - ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id - WHERE ps.name = @schema AND pt.name = @table - ORDER BY fk.name, fkc.constraint_column_id` - ) - - const foreignKeys = fkResult.recordset.map((row: ForeignKeyRow) => ({ + const primaryKeyColumns = (pkByTable.get(tableName) ?? []).map((row) => row.COLUMN_NAME) + + const foreignKeys = (fkByTable.get(tableName) ?? []).map((row) => ({ column: row.COLUMN_NAME, referencesSchema: row.REFERENCED_TABLE_SCHEMA, referencesTable: row.REFERENCED_TABLE_NAME, @@ -734,53 +983,8 @@ export async function executeIntrospect( if (!fkByColumn.has(fk.column)) fkByColumn.set(fk.column, fk) } - const indexResult = await pool - .request() - .input('schema', tableSchema) - .input('table', tableName) - .query( - /** - * `key_ordinal > 0` restricts the result to key columns: it is the - * "ordinal (1-based) within set of key-columns", and `0` marks INCLUDEd - * non-key columns, partitioning columns, **and every column of an XML, - * spatial, columnstore, or JSON index**. The partitioning columns are - * why `is_included_column` alone is not enough — those report `0` for it - * too. The index families are the cost of the filter: they contribute no - * key column, so they are absent from the result rather than listed with - * an empty column set. Rowstore keys, which is what a query planner - * reader is after, are reported in full. - * - * `is_hypothetical = 0` drops the statistics-only indexes the Database - * Engine Tuning Advisor leaves behind ("can't be used directly as a data - * access path"), and `is_disabled = 0` drops indexes that exist but are - * not maintained. Reporting either as a live index misleads. - * - * `is_primary_key = 0` keeps the primary key out, since `primaryKey` - * carries it already. A UNIQUE *constraint* is deliberately left in: it - * is a unique index and nothing else in the result reports it. - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-index-columns-transact-sql - * @see https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-indexes-transact-sql - */ - `SELECT i.name AS INDEX_NAME, c.name AS COLUMN_NAME, i.is_unique AS IS_UNIQUE - FROM sys.indexes i - JOIN sys.index_columns ic - ON i.object_id = ic.object_id AND i.index_id = ic.index_id - JOIN sys.columns c - ON ic.object_id = c.object_id AND ic.column_id = c.column_id - JOIN sys.tables t ON i.object_id = t.object_id - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = @schema - AND t.name = @table - AND i.is_primary_key = 0 - AND i.is_hypothetical = 0 - AND i.is_disabled = 0 - AND i.name IS NOT NULL - AND ic.key_ordinal > 0 - ORDER BY i.name, ic.key_ordinal` - ) - const indexMap = new Map() - for (const row of indexResult.recordset as IndexRow[]) { + for (const row of indexRowsByTable.get(tableName) ?? []) { const indexName = row.INDEX_NAME if (!indexMap.has(indexName)) { indexMap.set(indexName, { name: indexName, columns: [], unique: Boolean(row.IS_UNIQUE) }) @@ -791,7 +995,7 @@ export async function executeIntrospect( const primaryKeySet = new Set(primaryKeyColumns) - const columns = columnsResult.recordset.map((col: ColumnRow) => { + const columns = (columnsByTable.get(tableName) ?? []).map((col) => { const columnName = col.COLUMN_NAME const fk = fkByColumn.get(columnName) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx new file mode 100644 index 00000000000..fe74a0467e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.test.tsx @@ -0,0 +1,256 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +/** jsdom ships no ResizeObserver; the editor observes its container to size the gutter. */ +globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver + +const { SECRET, searchTargetRef } = vi.hoisted(() => ({ + SECRET: 'SIM-TEST-CREDENTIAL-MARKER\nfixture-body-abc123\nend-of-fixture', + searchTargetRef: { current: null as Record | null }, +})) + +vi.mock('@sim/emcn', () => ({ + CODE_LINE_HEIGHT_PX: 21, + Code: { + Container: ({ children }: { children: ReactNode }) =>
{children}
, + Gutter: ({ children }: { children: ReactNode }) =>
{children}
, + Content: ({ + children, + editorRef, + }: { + children: ReactNode + editorRef?: React.RefObject + }) =>
{children}
, + Placeholder: ({ children, show }: { children: ReactNode; show: boolean }) => + show ?
{children}
: null, + }, + calculateGutterWidth: () => 24, + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), + Duplicate: () => null, + getCodeEditorProps: () => ({}), + highlight: (code: string) => code, + languages: { javascript: {}, python: {}, bash: {} }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + Check: () => null, + Wand: () => null, +})) + +vi.mock('react-simple-code-editor', () => ({ + default: ({ + value, + highlight, + onFocus, + onBlur, + }: { + value: string + highlight: (code: string) => string + onFocus: () => void + onBlur: () => void + }) => ( + <> +