diff --git a/docs/automations/core-actions/_examples.yaml b/docs/automations/core-actions/_examples.yaml index a58464ea34..eec9a15302 100644 --- a/docs/automations/core-actions/_examples.yaml +++ b/docs/automations/core-actions/_examples.yaml @@ -12,7 +12,7 @@ examples: Authorization: Bearer ${{ SECRETS.alerts.API_TOKEN }} http-poll-job: - title: Poll until complete + title: Poll until terminal status language: yaml code: | - ref: wait_for_export @@ -24,7 +24,7 @@ examples: Authorization: Bearer ${{ SECRETS.exports.API_TOKEN }} poll_interval: 5 poll_max_attempts: 24 - poll_condition: "lambda response: response['data'].get('status') == 'completed'" + poll_condition: "lambda response: response['data'].get('status') in ['completed', 'failed', 'cancelled']" http-paginate-results: title: Follow next page links diff --git a/docs/automations/core-actions/_manifest.yaml b/docs/automations/core-actions/_manifest.yaml index 15caf4a4e0..1da87dff6d 100644 --- a/docs/automations/core-actions/_manifest.yaml +++ b/docs/automations/core-actions/_manifest.yaml @@ -1,14 +1,95 @@ pages: - slug: request-actions/http title: HTTP + imports: | + import OAuthTokenExpressions from "/snippets/oauth-token-expressions.mdx"; + info: | + Connect to any REST API with the `core.http_request`, `core.http_poll`, and + `core.http_paginate` actions. + + ## Authentication + + Authenticate requests with an API key or a managed OAuth access token. + + ### API keys + + Store API keys and static tokens as + [Secrets](/automations/core-concepts/secrets), then add the secret to + `headers`. + + Pass a secret as a bearer token: + + ```yaml + headers: + Authorization: Bearer ${{ SECRETS.. }} + ``` + + If the API expects a dedicated key header, use the same expression: + + ```yaml + headers: + X-API-Key: ${{ SECRETS.. }} + ``` + + ### OAuth + + Connect an [OAuth integration](/automations/integrations/oauth-integrations), + then pass its managed access token as a bearer token. + + actions: - id: core.http_request examples: - http-request-basic - id: core.http_poll + info: | + ### Poll condition examples + + `poll_condition` receives a response with `data`, `status_code`, and + `headers`. The action stops polling when the lambda returns `True`. + + Prefer `!=` over a single success check when the API has exactly one + nonterminal state. Default a missing status to that state so a transient + response does not stop polling: + + ```python + lambda response: response["data"].get("status", "processing") != "processing" + ``` + + This stops on failures and cancellations that a positive + `status == "completed"` check would miss. + + If the API also uses nonterminal states such as `queued` or `pending`, + list its terminal states with `in`: + + ```python + lambda response: response["data"].get("status") in ["completed", "failed", "cancelled"] + ``` + + Keep `poll_max_attempts` above `0` to prevent indefinite polling if the + condition never matches. + + Stop on an HTTP status code: + + ```python + lambda response: response["status_code"] == 200 + ``` + + Stop when a response header has the expected value: + + ```python + lambda response: response["headers"].get("x-status") == "completed" + ``` examples: - http-poll-job - id: core.http_paginate + info: | + + For large lists, request a bounded page and process each batch in a + [While loop](/automations/core-actions/workflow-actions/while-loops) + until the API returns no next cursor or no items. This avoids returning + the full dataset at once. + examples: - http-paginate-results diff --git a/docs/automations/core-actions/request-actions/http.mdx b/docs/automations/core-actions/request-actions/http.mdx index 68c08a34e2..8d97d3c5f4 100644 --- a/docs/automations/core-actions/request-actions/http.mdx +++ b/docs/automations/core-actions/request-actions/http.mdx @@ -2,8 +2,44 @@ title: "HTTP" --- +import OAuthTokenExpressions from "/snippets/oauth-token-expressions.mdx"; + {/* Auto-generated by scripts/generate_core_action_docs.py; do not edit by hand. */} +Connect to any REST API with the `core.http_request`, `core.http_poll`, and +`core.http_paginate` actions. + +## Authentication + +Authenticate requests with an API key or a managed OAuth access token. + +### API keys + +Store API keys and static tokens as +[Secrets](/automations/core-concepts/secrets), then add the secret to +`headers`. + +Pass a secret as a bearer token: + +```yaml +headers: + Authorization: Bearer ${{ SECRETS.. }} +``` + +If the API expects a dedicated key header, use the same expression: + +```yaml +headers: + X-API-Key: ${{ SECRETS.. }} +``` + +### OAuth + +Connect an [OAuth integration](/automations/integrations/oauth-integrations), +then pass its managed access token as a bearer token. + + + ## `core.http_request` Perform a HTTP request to a given URL. @@ -145,6 +181,44 @@ Default: `true`. Perform a HTTP request to a given URL with optional polling. +### Poll condition examples + +`poll_condition` receives a response with `data`, `status_code`, and +`headers`. The action stops polling when the lambda returns `True`. + +Prefer `!=` over a single success check when the API has exactly one +nonterminal state. Default a missing status to that state so a transient +response does not stop polling: + +```python +lambda response: response["data"].get("status", "processing") != "processing" +``` + +This stops on failures and cancellations that a positive +`status == "completed"` check would miss. + +If the API also uses nonterminal states such as `queued` or `pending`, +list its terminal states with `in`: + +```python +lambda response: response["data"].get("status") in ["completed", "failed", "cancelled"] +``` + +Keep `poll_max_attempts` above `0` to prevent indefinite polling if the +condition never matches. + +Stop on an HTTP status code: + +```python +lambda response: response["status_code"] == 200 +``` + +Stop when a response header has the expected value: + +```python +lambda response: response["headers"].get("x-status") == "completed" +``` + ### Secrets Optional secrets: @@ -274,7 +348,7 @@ Default: `true`. ### Examples -**Poll until complete** +**Poll until terminal status** ```yaml - ref: wait_for_export @@ -286,13 +360,20 @@ Default: `true`. Authorization: Bearer ${{ SECRETS.exports.API_TOKEN }} poll_interval: 5 poll_max_attempts: 24 - poll_condition: "lambda response: response['data'].get('status') == 'completed'" + poll_condition: "lambda response: response['data'].get('status') in ['completed', 'failed', 'cancelled']" ``` ## `core.http_paginate` Paginate through a HTTP response. + + For large lists, request a bounded page and process each batch in a + [While loop](/automations/core-actions/workflow-actions/while-loops) + until the API returns no next cursor or no items. This avoids returning + the full dataset at once. + + ### Inputs diff --git a/docs/automations/integrations/oauth-integrations.mdx b/docs/automations/integrations/oauth-integrations.mdx index c087356406..bf05d115c5 100644 --- a/docs/automations/integrations/oauth-integrations.mdx +++ b/docs/automations/integrations/oauth-integrations.mdx @@ -3,7 +3,7 @@ title: "OAuth" description: "Connect OAuth providers to Tracecat and reference managed OAuth tokens in expressions: configure scopes, refresh credentials, and call APIs from actions and agents." --- -import RegistrySecretsAndOAuth from "/snippets/registry-secrets-and-oauth.mdx"; +import OAuthTokenExpressions from "/snippets/oauth-token-expressions.mdx"; ## Overview @@ -34,11 +34,11 @@ After you save a custom provider, connect it (complete the OAuth flow) when usin ## Use OAuth tokens in expressions - + ## OAuth and actions -Registry actions reference OAuth integrations but do not create them. Configure a built-in or custom provider in Integrations first, then declare `RegistryOAuthSecret` (Python) or `type: oauth` (YAML) so the action requires that integration at runtime. Syntax and examples are in [Use OAuth tokens in expressions](#use-oauth-tokens-in-expressions) above. +Registry actions reference OAuth integrations but do not create them. Configure a built-in or custom provider in Integrations first, then declare `RegistryOAuthSecret` (Python) or `type: oauth` (YAML) so the action requires that integration at runtime. See the OAuth declaration syntax and examples for [Python UDFs](/custom-actions/python-udf#python-udfs) and [YAML templates](/custom-actions/yaml-template#secrets). ## OAuth and MCP @@ -51,10 +51,6 @@ Remote MCP integrations can use an existing OAuth integration. For custom remote ## FAQ - - When you save a custom provider, Tracecat derives a provider ID from the display name or the ID you enter (slugified). If that value does not already start with `custom_`, the server prepends `custom_` so it does not collide with [built-in providers](https://github.com/TracecatHQ/tracecat/blob/main/tracecat/integrations/providers/__init__.py). If the ID is already taken, a numeric suffix is added. - - No. `RegistryOAuthSecret` and YAML `type: oauth` entries declare that the action *requires* an existing OAuth integration — they do not register one. Create the integration from [Configure a provider](#configure-a-provider) (or [contribute a built-in provider](#how-do-i-contribute-a-built-in-oauth-provider)). Once it exists, your action can reference its tokens via `${{ SECRETS._oauth... }}`. diff --git a/docs/docs.json b/docs/docs.json index bfce569467..d3eabd2856 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -75,6 +75,7 @@ }, { "group": "Integrations", + "expanded": true, "pages": [ "automations/integrations/prebuilt-credentials", "automations/integrations/oauth-integrations", diff --git a/docs/snippets/oauth-token-expressions.mdx b/docs/snippets/oauth-token-expressions.mdx new file mode 100644 index 0000000000..394785d65d --- /dev/null +++ b/docs/snippets/oauth-token-expressions.mdx @@ -0,0 +1,50 @@ +--- +title: "OAuth token expressions" +description: "Reference OAuth provider IDs and managed tokens in Tracecat expressions." +--- + +OAuth expressions use the provider's exact ID, not its display name. + +- Built-in providers use stable lowercase IDs assigned by Tracecat, with + underscores between words, such as `slack`, `google_drive`, and + `microsoft_sentinel`. +- Custom providers use an ID derived from the provider name, or from the + requested ID when you create one through the API. Tracecat slugifies it with + underscores and prepends `custom_`. `My Security API` becomes + `custom_my_security_api`. If that ID is already used for the same grant type, + Tracecat appends `_1`, `_2`, and so on. + +Append `_oauth` to the exact provider ID for the secret name. For the key, +uppercase the complete provider ID, preserve its underscores and any numeric +suffix, then append `_USER_TOKEN` for `authorization_code` or `_SERVICE_TOKEN` +for `client_credentials`. + +```yaml +# authorization_code grant +${{ SECRETS._oauth._USER_TOKEN }} + +# client_credentials grant +${{ SECRETS._oauth._SERVICE_TOKEN }} +``` + +A built-in `google_drive` authorization-code provider and a custom +`custom_my_security_api` client-credentials provider resolve as: + +```yaml +${{ SECRETS.google_drive_oauth.GOOGLE_DRIVE_USER_TOKEN }} +${{ SECRETS.custom_my_security_api_oauth.CUSTOM_MY_SECURITY_API_SERVICE_TOKEN }} +``` + +When either grant type is allowed, use a fallback: + +```yaml +${{ SECRETS.microsoft_sentinel_oauth.MICROSOFT_SENTINEL_USER_TOKEN || SECRETS.microsoft_sentinel_oauth.MICROSOFT_SENTINEL_SERVICE_TOKEN }} +``` + + + Tracecat refreshes expiring authorization-code tokens when the provider + issued a refresh token, and reacquires client-credentials tokens with the + stored client credentials. The expression resolves only to the current + access-token string, which may be a JWT or an opaque token, not the refresh + token. + diff --git a/docs/snippets/registry-secrets-and-oauth.mdx b/docs/snippets/registry-secrets-and-oauth.mdx index fcaa6f2973..fd5463acdf 100644 --- a/docs/snippets/registry-secrets-and-oauth.mdx +++ b/docs/snippets/registry-secrets-and-oauth.mdx @@ -1,3 +1,5 @@ +import OAuthTokenExpressions from "/snippets/oauth-token-expressions.mdx"; + Use the same `${{ SECRETS... }}` syntax as workflow actions. Custom secrets (API keys, SSH, mTLS, CA bundles, and so on): @@ -6,21 +8,9 @@ Custom secrets (API keys, SSH, mTLS, CA bundles, and so on): ${{ SECRETS.. }} ``` -OAuth tokens live under `_oauth`. The key is the provider ID in uppercase plus `_USER_TOKEN` or `_SERVICE_TOKEN`: - -- `authorization_code`: `${{ SECRETS._oauth._USER_TOKEN }}` -- `client_credentials`: `${{ SECRETS._oauth._SERVICE_TOKEN }}` - -```yaml -${{ SECRETS.slack_oauth.SLACK_USER_TOKEN }} -${{ SECRETS.google_docs_oauth.GOOGLE_DOCS_SERVICE_TOKEN }} -``` - -Optional fallback when either grant type is allowed: +## OAuth -```yaml -${{ SECRETS.microsoft_sentinel_oauth.MICROSOFT_SENTINEL_USER_TOKEN || SECRETS.microsoft_sentinel_oauth.MICROSOFT_SENTINEL_SERVICE_TOKEN }} -``` + ## Python UDFs diff --git a/scripts/generate_core_action_docs.py b/scripts/generate_core_action_docs.py index 73d4c7e173..ffbd601491 100644 --- a/scripts/generate_core_action_docs.py +++ b/scripts/generate_core_action_docs.py @@ -286,10 +286,16 @@ def _render_page( f'title: "{title}"', "---", "", - AUTO_GENERATED_COMMENT, - "", ] + if imports_block := page.get("imports"): + lines.extend([str(imports_block).strip(), ""]) + + lines.extend([AUTO_GENERATED_COMMENT, ""]) + + if info_block := page.get("info"): + lines.extend([str(info_block).strip(), ""]) + for action_entry in action_entries: action_id = action_entry["id"] action = actions_by_id[action_id]