diff --git a/.changeset/26710-signed-connector-auth-aliyun.md b/.changeset/26710-signed-connector-auth-aliyun.md new file mode 100644 index 00000000000..fd6b1b97cf5 --- /dev/null +++ b/.changeset/26710-signed-connector-auth-aliyun.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/connector-rest': minor +'@objectstack/connector-openapi': minor +--- + +feat(spec): `signed` joins declarative connector-instance auth, with `aliyun-acs3-hmac-sha256` as its first scheme (ADR-0143 D2.3, #26710) + +Clause-②: yes (widening) +Metadata-protocol: added + + + +An upstream that authenticates every request by a signature over the request itself, rather than by a token it is sent, had no way to be declared on a connector instance. ADR-0143 D2.3 adds that kind, `signed`, with a closed list of schemes. A scheme joins the list only together with its signer and its vendor test vectors. This release ships one scheme: Alibaba Cloud's OpenAPI V3 signature. + +- **`@objectstack/spec`** + - `ConnectorInstanceAuthSchema` gains `{ type: 'signed', scheme, keyId, credentialRef }`, a closed shape for each scheme. `keyId` is the public key identifier. The signing key is only ever a `credentialRef`. + - The publish door refuses an unknown or missing `scheme` at `auth.scheme`, naming the value it got and the schemes that ship. It refuses an inline `secret`, `privateKey`, `secretKey` or `accessKeySecret` by name, with the `credentialRef` prescription, and an authored `signature`. It refuses `signed` on `provider: 'mcp'` at `auth.type`. + - `@objectstack/spec/integration` gains the request-signer seam: `ConnectorRequestSigner` (`sign`, plus an optional `verifyResponse` for schemes that sign their responses), `ConnectorProviderContext.requestSigner`, and `ConnectorMaterialization.requestSignerApplied`. + - `connectorFetchOptions` takes an optional signer and signs every attempt `resilientFetch` makes, retries included. `readConnectorResponse` runs the scheme's response verification before parsing. + - With no signer, both behave exactly as before. +- **`@objectstack/service-automation`** + - A `signed` instance's key resolves through the same credential chain as the static kinds, with the same degrade on an unresolved reference. + - The key is bound to the scheme's signer at materialization, and the provider receives only the signer. + - The service refuses to serve a `signed` instance on a provider that does not acknowledge the signer, so such an instance never calls its upstream unsigned. + - A datasource that borrows a `signed` instance through `external.connector` is refused with `422`. +- **`@objectstack/connector-rest`, `@objectstack/connector-openapi`** + - Both apply a handed request signer to every request and acknowledge it. + - A static credential together with a signer is refused. + - `bearer`, `api-key` and `basic` requests are unchanged. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 0c29e923cdb..760ea535a62 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -467,6 +467,60 @@ them, declare a static kind (`bearer`, `api-key` or `basic`) to dispatch now; don't emulate a grant by putting a short-lived access token behind a `bearer` ref. +### Request signing: `signed` + +Some upstreams take no token at all: every request carries a signature over its +own method, path, query, headers and body, computed with a key the caller never +sends. The `signed` kind declares that (ADR-0143 D2.3). The metadata names the +**scheme** and the public **key id**; the signing key itself is a +`credentialRef`, resolved through the same chain as every other kind. + +```ts +// Alibaba Cloud OpenAPI, V3 signature (ACS3-HMAC-SHA256): +auth: { + type: 'signed', + scheme: 'aliyun-acs3-hmac-sha256', + keyId: 'YourAccessKeyId', // the AccessKey ID: public, stays in metadata + credentialRef: 'ALIYUN_ACCESS_KEY_SECRET', +} +``` + +What the platform does with it: + +- **Every request is signed, each attempt fresh.** A retry is signed again with + a new nonce and date, so a scheme that refuses a replayed nonce accepts it. +- **The key never reaches the provider.** The automation service binds the + resolved key to the scheme's signer at materialization and hands the + provider the signer only. A datasource cannot borrow a `signed` instance + (`external.connector` is refused with `422`): a driver cannot sign per + request. +- **Schemes that sign their responses are verified** before a result is + returned; a response that does not verify fails the dispatch. +- **`aliyun-acs3-hmac-sha256`** signs `host`, `content-type` and every + `x-acs-*` header, and sets `x-acs-date`, `x-acs-signature-nonce`, + `x-acs-content-sha256` and `Authorization`. The API's own action and version + are yours to send, as `x-acs-action` / `x-acs-version` in `defaultHeaders` + (rest) or in each call's headers. + +What the publish door refuses: + +- **An unknown `scheme`**, at `auth.scheme`, naming the scheme it got and the + ones this platform ships. A scheme joins the list only together with its + signer and its test vectors, so a scheme the server cannot run is never + accepted. +- **An inline key**: `secret`, `privateKey`, `secretKey` or `accessKeySecret` + is refused by name, pointing at `credentialRef`. An authored `signature` is + refused too: it is computed for every request. +- **`provider: 'mcp'`**, at `auth.type`: an MCP server is not signed per + request. Declare `provider: 'rest'` or `provider: 'openapi'`. + +`signed` and a static credential do not combine: a request is authenticated +one way. An `openapi` instance's remote `spec` URL is fetched unsigned, as +before; only the API calls are signed. A third-party provider factory serves a +`signed` instance only when it says it applies the signer +(`requestSignerApplied` on its materialization); one that does not is refused at +boot rather than allowed to call the upstream unsigned. + ### The runtime shape: `authentication` The `authentication` field is the **runtime** auth config, with four static @@ -517,6 +571,7 @@ retry): | `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` | | Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory | | `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a server that does not yet run its per-dispatch resolution | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` | +| `auth` is `signed` on a provider that does not say it applies the request signer | **Hard boot error** naming the instance, the scheme and the provider: its calls would go out unsigned — declare it through `rest` or `openapi` | | `credentialRef` resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart | | The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | | Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 0f682071360..cab4ec45df8 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1566 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1569 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -25,7 +25,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | -| [Integration Protocol](/docs/references/integration) | 1 | 18 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 21 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **203** | **1566** | 14 protocol modules | +| **Total** | **203** | **1569** | 14 protocol modules | --- @@ -191,13 +191,13 @@ Users and accounts, organizations, positions, SCIM provisioning. ## Integration Protocol -**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 18 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 21 schemas** The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | File | Schemas | | :--- | :--- | -| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | +| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorInstanceSignedAliyunAcs3Auth`, `ConnectorInstanceSignedAuth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorSigningScheme`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 4a42d19d55d..bc75ad32077 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -165,8 +165,8 @@ an exception for itself.) ## TypeScript Usage ```typescript -import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorAppCredentialVendorSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorOAuth2GrantSchema, ConnectorRetryStrategySchema, ConnectorTypeSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema } from '@objectstack/spec/integration'; -import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorAppCredentialVendor, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAppCredentialAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorInstanceOAuth2Auth, ConnectorInstanceOAuth2AuthorizationCodeAuth, ConnectorInstanceOAuth2ClientCredentialsAuth, ConnectorOAuth2Grant, ConnectorRetryStrategy, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; +import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorAppCredentialVendorSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorInstanceSignedAliyunAcs3AuthSchema, ConnectorInstanceSignedAuthSchema, ConnectorOAuth2GrantSchema, ConnectorRetryStrategySchema, ConnectorSigningSchemeSchema, ConnectorTypeSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema } from '@objectstack/spec/integration'; +import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorAppCredentialVendor, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAppCredentialAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorInstanceOAuth2Auth, ConnectorInstanceOAuth2AuthorizationCodeAuth, ConnectorInstanceOAuth2ClientCredentialsAuth, ConnectorInstanceSignedAliyunAcs3Auth, ConnectorInstanceSignedAuth, ConnectorOAuth2Grant, ConnectorRetryStrategy, ConnectorSigningScheme, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; // Validate data const result = ConnectorSchema.parse(data); @@ -188,7 +188,7 @@ const result = ConnectorSchema.parse(data); | **authentication** | `{ type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string } \| … +2 more` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string } \| … +3 more` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | | **triggers** | `never` | optional | [REMOVED] `connector.triggers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a connector trigger never started anything: `AutomationEngine.registerConnector` registers a connector's actions only, no polling loop read `intervalSeconds` (or the `interval` spelling it was renamed from), and no receiver was driven by a `webhook` trigger. Delete the key; the `ConnectorTrigger` shape leaves with it. To start work from an external system, write a flow that calls the connector's action in a `connector_action` node: for an external event, an `api` flow that the event's sender calls; for a scheduled pull, a `schedule` flow. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -293,6 +293,15 @@ const result = ConnectorSchema.parse(data); | **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | +### Nested Shape: `Connector.auth[option 7]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'aliyun-acs3-hmac-sha256'` | ✅ | Alibaba Cloud OpenAPI V3 signature (ACS3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The AccessKey ID (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret. | + ### Nested Shape: `Connector.actions[number]` | Property | Type | Required | Description | @@ -470,6 +479,12 @@ Type: `{ type: 'oauth2'; tokenUrl?: string; scopes?: string[]; resource?: string --- +#### Option 7 + +Type: `{ type: 'signed'; scheme: 'aliyun-acs3-hmac-sha256'; keyId: string; credentialRef: string }` + +--- + --- @@ -588,6 +603,44 @@ This schema accepts one of the following structures: | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | +--- + +## ConnectorInstanceSignedAliyunAcs3Auth + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'aliyun-acs3-hmac-sha256'` | ✅ | Alibaba Cloud OpenAPI V3 signature (ACS3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The AccessKey ID (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret. | + + +--- + +## ConnectorInstanceSignedAuth + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +**Type:** `signed` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'aliyun-acs3-hmac-sha256'` | ✅ | Alibaba Cloud OpenAPI V3 signature (ACS3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The AccessKey ID (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret. | + +--- + + --- ## ConnectorOAuth2Grant @@ -614,6 +667,17 @@ Retry strategy * `no_retry` +--- + +## ConnectorSigningScheme + +Request-signing scheme: `aliyun-acs3-hmac-sha256` (Alibaba Cloud OpenAPI V3 signature). + +### Allowed Values + +* `aliyun-acs3-hmac-sha256` + + --- ## ConnectorType @@ -646,7 +710,7 @@ Connector type | **authentication** | `{ type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string } \| … +2 more` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string } \| … +3 more` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | | **triggers** | `never` | optional | [REMOVED] `connector.triggers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a connector trigger never started anything: `AutomationEngine.registerConnector` registers a connector's actions only, no polling loop read `intervalSeconds` (or the `interval` spelling it was renamed from), and no receiver was driven by a `webhook` trigger. Delete the key; the `ConnectorTrigger` shape leaves with it. To start work from an external system, write a flow that calls the connector's action in a `connector_action` node: for an external event, an `api` flow that the event's sender calls; for a scheduled pull, a `schedule` flow. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -751,6 +815,15 @@ Connector type | **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | +### Nested Shape: `DeclarativeConnectorEntry.auth[option 7]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'signed'` | ✅ | | +| **scheme** | `'aliyun-acs3-hmac-sha256'` | ✅ | Alibaba Cloud OpenAPI V3 signature (ACS3-HMAC-SHA256). | +| **keyId** | `string` | ✅ | The AccessKey ID (not a secret); sent in the `Credential` of every signature. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret. | + ### Nested Shape: `DeclarativeConnectorEntry.actions[number]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md index 858a34e15d6..7aa70ec0af6 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `shared/` | 23 | +| `shared/` | 24 | diff --git a/packages/connectors/connector-openapi/src/openapi-connector.ts b/packages/connectors/connector-openapi/src/openapi-connector.ts index ba7fffc1c72..85eb1027ad2 100644 --- a/packages/connectors/connector-openapi/src/openapi-connector.ts +++ b/packages/connectors/connector-openapi/src/openapi-connector.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Connector, RetryConfig } from '@objectstack/spec/integration'; -import { connectorFetchOptions } from '@objectstack/spec/integration'; +import type { Connector, ConnectorRequestSigner, RetryConfig } from '@objectstack/spec/integration'; +import { connectorFetchOptions, readConnectorResponse } from '@objectstack/spec/integration'; import { resilientFetch } from '@objectstack/spec/shared'; /** @@ -22,6 +22,12 @@ import { resilientFetch } from '@objectstack/spec/shared'; * Open-source scope: **static** auth only (`none` / `api-key` / `basic` / * `bearer`), with credentials supplied by the caller. Managed OAuth2, credential * vaulting, and per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022). + * + * A request-signing instance (ADR-0143 D2.3) passes a `requestSigner` instead + * of a credential: every attempt is signed by it — retries included — and its + * response verification, when the scheme has one, runs before a body is + * returned (`connectorFetchOptions` / `readConnectorResponse`, shared with + * `@objectstack/connector-rest`). */ /** Static auth understood by the generated connector (the open-source subset). */ @@ -120,6 +126,12 @@ export interface OpenApiConnectorConfig { baseUrl?: string; /** Static auth with credentials. Defaults to `{ type: 'none' }`. */ auth?: RestAuth; + /** + * Per-request signer of a `signed` instance (ADR-0143 D2.3): signs every + * attempt and verifies every response its scheme signs. Exclusive with a + * static `auth` other than `none` — a request is authenticated one way. + */ + requestSigner?: ConnectorRequestSigner; /** Headers merged into every request (request-level headers win). */ defaultHeaders?: Record; /** Only include operations for which this predicate returns true (allowlist). */ @@ -169,6 +181,7 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC const fetchOptions = connectorFetchOptions( { retryConfig: config.retryConfig, requestTimeoutMs: config.requestTimeoutMs }, { fetchImpl: config.fetchImpl }, + config.requestSigner, ); const name = config.name ?? slug(document.info?.title ?? 'openapi_connector'); const label = config.label ?? document.info?.title ?? titleize(name); @@ -177,6 +190,12 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC if (!baseUrl) { throw new Error('createOpenApiConnector: no base URL — provide config.baseUrl or document.servers[0].url'); } + if (config.requestSigner && auth.type !== 'none') { + throw new Error( + `createOpenApiConnector: connector '${name}' is given both a static '${auth.type}' credential and a request ` + + 'signer; a request is authenticated one way — pass one of them.', + ); + } // One shared transport (mirrors connector-rest) reused by every action handler. async function request(input: RequestInput): Promise> { @@ -197,9 +216,9 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC body: hasBody ? JSON.stringify(input.body) : undefined, }, fetchOptions); - const contentType = response.headers.get('content-type') ?? ''; - const parsed = contentType.includes('application/json') ? await response.json() : await response.text(); - return { status: response.status, ok: response.ok, body: parsed }; + // A signed instance's response is verified first, when its scheme + // signs responses. + return readConnectorResponse(response, config.requestSigner); } const actions: ConnectorAction[] = []; diff --git a/packages/connectors/connector-openapi/src/openapi-provider.test.ts b/packages/connectors/connector-openapi/src/openapi-provider.test.ts index c17087bf5ac..fe7d2617d04 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.test.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from 'vitest'; import { LiteKernel } from '@objectstack/core'; -import type { ConnectorProviderContext } from '@objectstack/spec/integration'; +import type { ConnectorProviderContext, ConnectorRequestSigner } from '@objectstack/spec/integration'; import { isConnectorUpstreamUnavailable, RetryConfigSchema } from '@objectstack/spec/integration'; import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; import { ConnectorOpenApiPlugin } from './connector-openapi-plugin.js'; @@ -566,3 +566,78 @@ describe('openapi provider — path-item-level parameters', () => { }); }); }); + +// ADR-0143 D2.3 — a `signed` instance arrives with a request signer instead of +// a credential. Every generated action signs every attempt with it (a retry +// included), runs its response verification before answering, and the +// provider says it does so — the host serves a `signed` instance on nothing less. +describe('openapi provider — a signed instance (ADR-0143 D2.3)', () => { + function signerDouble(over: Partial = {}) { + const signed: string[] = []; + const signer: ConnectorRequestSigner = { + connector: 'pets', + scheme: 'aliyun-acs3-hmac-sha256', + async sign(request) { + signed.push(`${request.method} ${request.url}`); + return { headers: { authorization: `SIG-${signed.length}` } }; + }, + ...over, + }; + return { signer, signed }; + } + + function statusFetch(statuses: number[]) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response('[{"id":1}]', { status: statuses.shift() ?? 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + return { impl, calls }; + } + + it('signs every attempt, a retry included, and says it applies the signer', async () => { + const { signer, signed } = signerDouble(); + const { impl, calls } = statusFetch([503, 200]); + const materialization = await createOpenApiProviderFactory({ fetchImpl: impl })(ctx({ + providerConfig: { spec: petstore }, + requestSigner: signer, + retryConfig: policy({ strategy: 'fixed_delay', maxAttempts: 2, initialDelayMs: 100, retryableStatusCodes: [503], jitter: false }), + })); + expect(materialization.requestSignerApplied).toBe(true); + + const out = await materialization.handlers.listPets({ query: { limit: 2 } }, {}); + expect(out).toEqual({ status: 200, ok: true, body: [{ id: 1 }] }); + expect(signed).toEqual(['GET https://petstore.example.com/pets?limit=2', 'GET https://petstore.example.com/pets?limit=2']); + expect(calls.map((c) => (c.init.headers as Record).authorization)).toEqual(['SIG-1', 'SIG-2']); + }); + + it("a response that fails the scheme's verification fails the dispatch", async () => { + const { signer } = signerDouble({ async verifyResponse() { throw new Error('response signature does not verify'); } }); + const { impl } = statusFetch([200]); + const { handlers } = await createOpenApiProviderFactory({ fetchImpl: impl })(ctx({ + providerConfig: { spec: petstore }, + requestSigner: signer, + })); + await expect(handlers.listPets({}, {})).rejects.toThrow(/does not verify/); + }); + + it('CONTROL: a static instance carries no signer acknowledgement and its credential is applied as before', async () => { + const { impl, calls } = statusFetch([200]); + const materialization = await createOpenApiProviderFactory({ fetchImpl: impl })(ctx({ + providerConfig: { spec: petstore }, + auth: { type: 'bearer', token: 'tok' }, + })); + expect('requestSignerApplied' in materialization).toBe(false); + await materialization.handlers.listPets({}, {}); + expect((calls[0]!.init.headers as Record).Authorization).toBe('Bearer tok'); + }); + + it('refuses a static credential and a signer together', async () => { + const { signer } = signerDouble(); + await expect(createOpenApiProviderFactory({})(ctx({ + providerConfig: { spec: petstore }, + auth: { type: 'bearer', token: 't' }, + requestSigner: signer, + }))).rejects.toThrow(/authenticated one way/); + }); +}); diff --git a/packages/connectors/connector-openapi/src/openapi-provider.ts b/packages/connectors/connector-openapi/src/openapi-provider.ts index 9609ea65091..ded7449ce37 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.ts @@ -276,6 +276,8 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co document, baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : undefined, auth, + // ADR-0143 D2.3 — a `signed` instance's signer, applied to every attempt. + requestSigner: ctx.requestSigner, include, // ADR-0049 · #18975 — the authored resilience policy, already resolved by // the materializer, reaches the transport this bundle closes over. @@ -303,6 +305,8 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co ); } } - return bundle; + // Says so, so the host serves the `signed` instance (it refuses one whose + // provider does not apply the signer). + return ctx.requestSigner ? { ...bundle, requestSignerApplied: true } : bundle; }; } diff --git a/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts b/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts index 7d6fb4d212f..96a2fc4ff04 100644 --- a/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts +++ b/packages/connectors/connector-rest/src/connector-rest-plugin.test.ts @@ -211,3 +211,63 @@ describe('ConnectorRestPlugin — a credential the upstream refuses (#27237)', ( await kernel.shutdown(); }); }); + +// ADR-0143 D2.3 end to end, on the REAL host and the REAL rest provider: a +// declarative `signed` instance is materialized with the host's signer, every +// call carries an ACS3-HMAC-SHA256 signature, and the signing key appears in +// nothing the instance sends or publishes. (The signature's bytes are pinned +// against the vendor's worked example in service-automation's signer test.) +describe('ConnectorRestPlugin — a declarative `signed` instance (ADR-0143 D2.3)', () => { + it('signs every call with the key its credentialRef names, and never sends or lists the key', async () => { + const KEY = 'dummy-aliyun-access-key-secret-26710'; + const calls: Array<{ url: string; headers: Record }> = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, headers: init.headers as Record }); + return new Response('{"RequestId":"r-1"}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + const declared = [{ + name: 'aliyun_ecs', + label: 'Aliyun ECS', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://ecs.cn-shanghai.aliyuncs.com', defaultHeaders: { 'x-acs-version': '2014-05-26' } }, + auth: { type: 'signed', scheme: 'aliyun-acs3-hmac-sha256', keyId: 'YourAccessKeyId', credentialRef: 'ALIYUN_SECRET_26710' }, + }]; + const kernel = new LiteKernel(); + kernel.use(new AutomationServicePlugin({ credentialResolver: (r) => (r === 'ALIYUN_SECRET_26710' ? KEY : undefined) })); + kernel.use(new ConnectorRestPlugin({ fetchImpl: impl })); + kernel.use({ + name: 'test.metadata', + type: 'standard', + version: '1.0.0', + dependencies: [], + async init(ctx: any) { + ctx.registerService('objectql', { registry: { listItems: (t: string) => (t === 'connector' ? declared : []) } }); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + const engine = kernel.getService('automation'); + expect(engine.getConnectorDegradedReason('aliyun_ecs')).toBeUndefined(); + + const request = engine.resolveConnectorAction('aliyun_ecs', 'request')!; + const answered = await request({ method: 'POST', path: '/', query: { RegionId: 'cn-shanghai' }, headers: { 'x-acs-action': 'DescribeRegions' } }, {} as never); + await request({ method: 'POST', path: '/', query: { RegionId: 'cn-shanghai' }, headers: { 'x-acs-action': 'DescribeRegions' } }, {} as never); + expect(answered).toEqual({ status: 200, ok: true, body: { RequestId: 'r-1' } }); + + expect(calls).toHaveLength(2); + expect(calls[0]!.url).toBe('https://ecs.cn-shanghai.aliyuncs.com/?RegionId=cn-shanghai'); + expect(calls[0]!.headers.authorization).toMatch( + /^ACS3-HMAC-SHA256 Credential=YourAccessKeyId,SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version,Signature=[0-9a-f]{64}$/, + ); + expect(calls[0]!.headers['x-acs-action']).toBe('DescribeRegions'); + // Two calls, two nonces, two signatures: nothing is replayed. + expect(calls[0]!.headers['x-acs-signature-nonce']).not.toBe(calls[1]!.headers['x-acs-signature-nonce']); + expect(calls[0]!.headers.authorization).not.toBe(calls[1]!.headers.authorization); + + expect(JSON.stringify(calls)).not.toContain(KEY); + expect(JSON.stringify(engine.getConnectorDescriptors())).not.toContain(KEY); + + await kernel.shutdown(); + }); +}); diff --git a/packages/connectors/connector-rest/src/rest-connector.ts b/packages/connectors/connector-rest/src/rest-connector.ts index 0b1a20220f3..737a193c804 100644 --- a/packages/connectors/connector-rest/src/rest-connector.ts +++ b/packages/connectors/connector-rest/src/rest-connector.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Connector, RetryConfig } from '@objectstack/spec/integration'; -import { connectorFetchOptions } from '@objectstack/spec/integration'; +import type { Connector, ConnectorRequestSigner, RetryConfig } from '@objectstack/spec/integration'; +import { connectorFetchOptions, readConnectorResponse } from '@objectstack/spec/integration'; import { resilientFetch } from '@objectstack/spec/shared'; /** @@ -15,6 +15,12 @@ import { resilientFetch } from '@objectstack/spec/shared'; * and refresh, credential vaulting, and multi-tenant connection lifecycle are * the enterprise tier (see `../cloud/docs/design/connector-tiering.md`) and are * deliberately out of scope here. + * + * A request-signing instance (ADR-0143 D2.3) passes a `requestSigner` instead + * of a credential: every attempt is signed by it — retries included — and its + * response verification, when the scheme has one, runs before a body is + * returned (`connectorFetchOptions` / `readConnectorResponse`, the one site + * for both built-in HTTP connectors). */ /** Auth config understood by the REST connector (the static subset). */ @@ -32,6 +38,12 @@ export interface RestConnectorOptions { baseUrl: string; /** Static authentication. Defaults to `{ type: 'none' }`. */ auth?: RestAuth; + /** + * Per-request signer of a `signed` instance (ADR-0143 D2.3): signs every + * attempt and verifies every response its scheme signs. Exclusive with a + * static `auth` other than `none` — a request is authenticated one way. + */ + requestSigner?: ConnectorRequestSigner; /** Headers merged into every request (request-level headers win). */ defaultHeaders?: Record; /** @@ -112,12 +124,19 @@ function applyAuth( export function createRestConnector(opts: RestConnectorOptions): RestConnectorBundle { const name = opts.name ?? 'rest'; const auth: RestAuth = opts.auth ?? { type: 'none' }; + if (opts.requestSigner && auth.type !== 'none') { + throw new Error( + `createRestConnector: connector '${name}' is given both a static '${auth.type}' credential and a request signer; ` + + 'a request is authenticated one way — pass one of them.', + ); + } // Resolved once per connector, not per request: the policy is fixed for the // bundle's lifetime, and a declarative instance re-materializes (with a new // bundle) whenever the author edits it. const fetchOptions = connectorFetchOptions( { retryConfig: opts.retryConfig, requestTimeoutMs: opts.requestTimeoutMs }, { fetchImpl: opts.fetchImpl }, + opts.requestSigner, ); const def: Connector = { @@ -183,13 +202,9 @@ export function createRestConnector(opts: RestConnectorOptions): RestConnectorBu }, fetchOptions); // Parse JSON when advertised; fall back to text so non-JSON endpoints - // don't throw. - const contentType = response.headers.get('content-type') ?? ''; - const parsed = contentType.includes('application/json') - ? await response.json() - : await response.text(); - - return { status: response.status, ok: response.ok, body: parsed }; + // don't throw. A signed instance's response is verified first, when + // its scheme signs responses. + return readConnectorResponse(response, opts.requestSigner); } return { def, handlers: { request } }; diff --git a/packages/connectors/connector-rest/src/rest-provider.test.ts b/packages/connectors/connector-rest/src/rest-provider.test.ts index 296109f7201..a9451a6f9a5 100644 --- a/packages/connectors/connector-rest/src/rest-provider.test.ts +++ b/packages/connectors/connector-rest/src/rest-provider.test.ts @@ -4,7 +4,7 @@ // `provider: 'rest'` connector instance from providerConfig + resolved auth. import { describe, it, expect } from 'vitest'; -import type { ConnectorProviderContext, RetryConfig } from '@objectstack/spec/integration'; +import type { ConnectorProviderContext, ConnectorRequestSigner, RetryConfig } from '@objectstack/spec/integration'; import { RetryConfigSchema } from '@objectstack/spec/integration'; import { createRestProviderFactory, REST_PROVIDER_KEY } from './rest-provider.js'; @@ -246,3 +246,81 @@ describe('rest provider factory (ADR-0097)', () => { }); }); }); + +// ADR-0143 D2.3 — a `signed` instance arrives with a request signer instead of +// a credential. The provider signs every attempt with it (a retry included), +// runs its response verification before answering, and says it does so. +describe('rest provider factory — a signed instance (ADR-0143 D2.3)', () => { + function signerDouble(over: Partial = {}) { + const signed: string[] = []; + const signer: ConnectorRequestSigner = { + connector: 'aliyun_ecs', + scheme: 'aliyun-acs3-hmac-sha256', + async sign(request) { + signed.push(`${request.method} ${request.url}`); + return { headers: { authorization: `SIG-${signed.length}` } }; + }, + ...over, + }; + return { signer, signed }; + } + + function statusFetch(statuses: number[]) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response('{"RequestId":"r"}', { status: statuses.shift() ?? 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + return { impl, calls }; + } + + it('signs every attempt, a retry included, and says it applies the signer', async () => { + const { signer, signed } = signerDouble(); + const { impl, calls } = statusFetch([503, 200]); + const materialization = await createRestProviderFactory({ fetchImpl: impl })(ctx({ + name: 'aliyun_ecs', + providerConfig: { baseUrl: 'https://ecs.cn-shanghai.aliyuncs.com' }, + requestSigner: signer, + retryConfig: policy({ strategy: 'fixed_delay', maxAttempts: 2, initialDelayMs: 100, retryableStatusCodes: [503], jitter: false }), + })); + expect(materialization.requestSignerApplied).toBe(true); + + const out = await materialization.handlers.request({ method: 'POST', path: '/', query: { RegionId: 'cn-shanghai' } }, {}); + expect(out).toEqual({ status: 200, ok: true, body: { RequestId: 'r' } }); + expect(signed).toEqual([ + 'POST https://ecs.cn-shanghai.aliyuncs.com/?RegionId=cn-shanghai', + 'POST https://ecs.cn-shanghai.aliyuncs.com/?RegionId=cn-shanghai', + ]); + expect(calls.map((c) => (c.init.headers as Record).authorization)).toEqual(['SIG-1', 'SIG-2']); + }); + + it('a response that fails the scheme\'s verification fails the dispatch', async () => { + const { signer } = signerDouble({ async verifyResponse() { throw new Error('response signature does not verify'); } }); + const { impl } = statusFetch([200]); + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })(ctx({ + providerConfig: { baseUrl: 'https://ecs.cn-shanghai.aliyuncs.com' }, + requestSigner: signer, + })); + await expect(handlers.request({ path: '/' }, {})).rejects.toThrow(/does not verify/); + }); + + it('CONTROL: a static instance carries no signer acknowledgement and its credential is applied as before', async () => { + const { impl, calls } = stubFetch(); + const materialization = await createRestProviderFactory({ fetchImpl: impl })(ctx({ + providerConfig: { baseUrl: 'https://api.example.com' }, + auth: { type: 'api-key', key: 'k', headerName: 'X-API-Key' }, + })); + expect('requestSignerApplied' in materialization).toBe(false); + await materialization.handlers.request({ path: '/ping' }, {}); + expect((calls[0].init.headers as Record)['X-API-Key']).toBe('k'); + }); + + it('refuses a static credential and a signer together', async () => { + const { signer } = signerDouble(); + await expect(Promise.resolve().then(() => createRestProviderFactory({})(ctx({ + providerConfig: { baseUrl: 'https://api.example.com' }, + auth: { type: 'bearer', token: 't' }, + requestSigner: signer, + })))).rejects.toThrow(/authenticated one way/); + }); +}); diff --git a/packages/connectors/connector-rest/src/rest-provider.ts b/packages/connectors/connector-rest/src/rest-provider.ts index 56aa358ba7c..8ad39275b6c 100644 --- a/packages/connectors/connector-rest/src/rest-provider.ts +++ b/packages/connectors/connector-rest/src/rest-provider.ts @@ -52,11 +52,13 @@ export function createRestProviderFactory(deps: RestProviderDeps = {}): Connecto // `ResolvedConnectorAuth` is exactly the static-auth subset RestAuth expects // (the credentialRef has already been resolved to a secret upstream). const auth = ctx.auth as ResolvedConnectorAuth | undefined as RestAuth | undefined; - return createRestConnector({ + const bundle = createRestConnector({ name: ctx.name, label: ctx.label, baseUrl: cfg.baseUrl, auth, + // ADR-0143 D2.3 — a `signed` instance's signer, applied to every attempt. + requestSigner: ctx.requestSigner, defaultHeaders: isStringRecord(cfg.defaultHeaders) ? cfg.defaultHeaders : undefined, // ADR-0049 · #18975 — the authored resilience policy, already resolved by // the materializer, reaches the transport this bundle closes over. @@ -64,5 +66,8 @@ export function createRestProviderFactory(deps: RestProviderDeps = {}): Connecto requestTimeoutMs: ctx.requestTimeoutMs, fetchImpl: deps.fetchImpl, }); + // Says so, so the host serves the `signed` instance (it refuses one whose + // provider does not apply the signer). + return ctx.requestSigner ? { ...bundle, requestSignerApplied: true } : bundle; }; } diff --git a/packages/services/service-automation/src/connector-credential-source.test.ts b/packages/services/service-automation/src/connector-credential-source.test.ts index 81031648e8e..417192d4652 100644 --- a/packages/services/service-automation/src/connector-credential-source.test.ts +++ b/packages/services/service-automation/src/connector-credential-source.test.ts @@ -339,3 +339,27 @@ describe('[ADR-0143 D4] createConnectorCredentialSource — a grant-bearing kind expect((refusal as Error).message).not.toContain('grant-bearing'); }); }); + +describe('[ADR-0143 D2.3] createConnectorCredentialSource — a `signed` instance is refused, not lent', () => { + // A signed instance's key signs each of its own requests and never leaves + // the host, so there is no credential to hand a datasource — and the + // borrow refuses without reading the chain. + it('refuses to lend a `signed` instance (422, naming the kind and the instance)', async () => { + const reads = { n: 0 }; + const lender = createConnectorCredentialSource({ + readDeclared: async () => [sap({ + auth: { type: 'signed', scheme: 'aliyun-acs3-hmac-sha256', keyId: 'YourAccessKeyId', credentialRef: REF }, + })], + chain: async () => { reads.n += 1; return { env: () => DUMMY_VALUE }; }, + vaultWired: () => false, + generation: () => 0, + }); + const refusal = await lender.borrow('sap_s4').catch((e: Error) => e); + expect(refusal).toMatchObject({ code: 'VALIDATION_ERROR', status: 422 }); + expect((refusal as Error).message).toContain("connector instance 'sap_s4'"); + expect((refusal as Error).message).toContain("auth: { type: 'signed' }"); + expect((refusal as Error).message).toContain('never leaves the server'); + expect((refusal as Error).message).not.toContain(DUMMY_VALUE); + expect(reads.n).toBe(0); + }); +}); diff --git a/packages/services/service-automation/src/connector-credential-source.ts b/packages/services/service-automation/src/connector-credential-source.ts index fd988aba5cd..f63702830d8 100644 --- a/packages/services/service-automation/src/connector-credential-source.ts +++ b/packages/services/service-automation/src/connector-credential-source.ts @@ -81,7 +81,7 @@ export interface ConnectorCredentialSourceDeps { type Lendable = LendableConnectorItem & { name: string; provider: string; - auth: Exclude; + auth: Exclude; }; function findDeclared(items: readonly unknown[] | undefined, name: string): LendableConnectorItem | undefined { @@ -124,6 +124,14 @@ function lendRefusal(connector: string, item: LendableConnectorItem | undefined) + 'cannot lend its credential to a datasource. Declare a static kind (bearer, api-key or basic) on it, or ' + 'remove external.connector from the datasource.'; } + // ADR-0143 D2.3 — a `signed` instance has no credential to lend: its key + // signs each request by the vendor's canonical form, which a datasource + // driver does not run, and the key itself never leaves the host. + if (auth.type === 'signed') { + return `${subject} declares \`auth: { type: 'signed' }\`, which signs each of its own requests with a key that ` + + 'never leaves the server, so it has no credential a datasource can borrow. Point external.connector at an ' + + 'instance with a static kind (bearer, api-key or basic), or remove it from the datasource.'; + } if (item.enabled === false) { return `${subject} is disabled (enabled: false), which turns the vendor off for this datasource's records as for ` + "the connector's actions. Enable the instance to read through it."; diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index bee684a7529..630e9cf2b8b 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -29,6 +29,7 @@ import { type CredentialResolver, } from './plugin.js'; import type { AutomationEngine } from './engine.js'; +import { computeAliyunAcs3Signature } from './request-signing/aliyun-acs3-hmac-sha256.js'; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -1091,3 +1092,103 @@ describe('ADR-0143 D4 — a grant-bearing instance is held degraded, never mater await kernel.shutdown(); }); }); + +// ADR-0143 D2.3, D4 — a `signed` instance's key is bound to the signer its +// scheme names by the host, and the provider receives only the signer. It is +// served only by a provider that says it applies the signer to every request; +// any other is a configuration fault, so the instance is never called unsigned. +describe('ADR-0143 D2.3 — a `signed` instance is handed a request signer, never its key', () => { + const SIGNING_KEY = 'resolved-signing-key'; + const signed: ConnectorInstanceAuth = { + type: 'signed', + scheme: 'aliyun-acs3-hmac-sha256', + keyId: 'YourAccessKeyId', + credentialRef: 'ALIYUN_SECRET', + }; + + /** A provider that applies the signer it is handed (and says so), recording each ctx. */ + function makeSigningProvider(applied: boolean | undefined) { + const calls: ConnectorProviderContext[] = []; + const closed: string[] = []; + const factory: ConnectorProviderFactory = (ctx) => { + calls.push(ctx); + return { + def: { + name: ctx.name, + label: ctx.label, + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'ping', label: 'Ping' }], + } as unknown as Connector, + handlers: { ping: async () => ({ ok: true }) }, + close: async () => { closed.push(ctx.name); }, + ...(applied === undefined ? {} : { requestSignerApplied: applied }), + }; + }; + return { factory, calls, closed }; + } + + it('materializes it with `requestSigner` bound to the resolved key, `auth` unset, and no key in the context', async () => { + const { factory, calls } = makeSigningProvider(true); + const kernel = await boot([providerConnector('aliyun_ecs', { auth: signed })], { + providerFactory: factory, + credentialResolver: (ref) => (ref === 'ALIYUN_SECRET' ? SIGNING_KEY : undefined), + }); + expect(calls).toHaveLength(1); + const ctx = calls[0]!; + expect(ctx.auth).toBeUndefined(); + expect([ctx.requestSigner?.connector, ctx.requestSigner?.scheme]).toEqual(['aliyun_ecs', 'aliyun-acs3-hmac-sha256']); + expect(JSON.stringify(ctx)).not.toContain(SIGNING_KEY); + + // The signer signs with the key the reference resolved to: recompute + // its signature from the instant and nonce it chose. + const request = { method: 'GET', url: 'https://ecs.cn-shanghai.aliyuncs.com/?RegionId=cn-shanghai', headers: {} }; + const signature = await ctx.requestSigner!.sign(request); + const expected = computeAliyunAcs3Signature(request, 'YourAccessKeyId', SIGNING_KEY, { + date: signature.headers['x-acs-date']!, + nonce: signature.headers['x-acs-signature-nonce']!, + }); + expect(signature.headers.authorization).toBe(expected.headers.authorization); + + const engine = automationOf(kernel); + expect(engine.getConnectorDescriptors().find((d) => d.name === 'aliyun_ecs')?.state).not.toBe('degraded'); + expect(engine.resolveConnectorAction('aliyun_ecs', 'ping')).toBeDefined(); + await kernel.shutdown(); + }); + + it.each([ + ['does not say it applies the signer', undefined], + ['says it does not apply the signer', false], + ] as const)('fails boot when the provider %s, and lets its connection go', async (_why, applied) => { + const { factory, closed } = makeSigningProvider(applied); + await expect(boot([providerConnector('aliyun_ecs', { auth: signed })], { + providerFactory: factory, + credentialResolver: () => SIGNING_KEY, + })).rejects.toThrow(/auth type 'signed'.*did not say it signs every request.*unsigned/s); + expect(closed).toEqual(['aliyun_ecs']); + }); + + it('degrades the instance naming the reference when the signing key does not resolve, without calling its factory', async () => { + const { factory, calls } = makeSigningProvider(true); + const kernel = await boot([providerConnector('aliyun_ecs', { auth: signed })], { + providerFactory: factory, + credentialResolver: () => undefined, + }); + expect(calls).toHaveLength(0); + const engine = automationOf(kernel); + expect(engine.getConnectorDescriptors().find((d) => d.name === 'aliyun_ecs')?.state).toBe('degraded'); + expect(engine.getConnectorDegradedReason('aliyun_ecs')).toContain('ALIYUN_SECRET'); + await kernel.shutdown(); + }); + + it('CONTROL: a static kind is handed its credential and no signer', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('k_bearer', { auth: { type: 'bearer', credentialRef: 'TOKEN' } })], { + providerFactory: factory, + credentialResolver: () => 'resolved-secret', + }); + expect(calls[0]!.auth).toEqual({ type: 'bearer', token: 'resolved-secret' }); + expect(calls[0]!.requestSigner).toBeUndefined(); + await kernel.shutdown(); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index bfc48e75893..58cfaf79a26 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -10,6 +10,7 @@ import type { ResolvedConnectorAuth, ConnectorProviderContext, ConnectorMaterializationHandler, + ConnectorRequestSigner, } from '@objectstack/spec/integration'; import { isConnectorCredentialRejected, isConnectorUpstreamUnavailable, RetryConfigSchema } from '@objectstack/spec/integration'; import { stripReadDecorations } from '@objectstack/spec/kernel'; @@ -58,6 +59,7 @@ import { } from './connector-credentials.js'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE } from '@objectstack/types'; import { createConnectorCredentialSource } from './connector-credential-source.js'; +import { createConnectorRequestSigner } from './request-signing/index.js'; import { CONNECTOR_CREDENTIALS_UI_BUNDLE } from './connector-credentials-ui.js'; import { FlowCredentialChannel, @@ -417,16 +419,32 @@ function grantBearingNotServedReason(name: string, auth: GrantBearingInstanceAut + 'to dispatch it now.'; } +/** The per-request signing instance auth kind (ADR-0143 D2.3): bound to a signer, never handed over as a value. */ +type SignedInstanceAuth = Extract; + +/** + * ADR-0143 D2.3 / ADR-0049 — why a `signed` instance is not served on a + * provider whose materialization did not say it applies the request signer: + * its handlers would call the upstream unsigned. + */ +function requestSignerNotAppliedReason(name: string, provider: string, auth: SignedInstanceAuth): string { + return `connector instance '${name}' declares auth type 'signed' (scheme '${auth.scheme}'), but provider '${provider}' ` + + 'did not say it signs every request with the request signer it was handed, so its calls would go out unsigned ' + + '(ADR-0143 D2.3). The built-in openapi and rest providers sign; declare the instance through one of them.'; +} + /** * Build the static {@link ResolvedConnectorAuth} a provider factory applies * from a declarative instance's `auth` and the secret its `credentialRef` * resolved to (ADR-0097 §3). `secret` is present whenever `auth` names a * reference — an unresolved one never reaches here: the reconcile degrades * the instance first (#27237). A grant-bearing kind never reaches here either: - * the reconcile holds it degraded ({@link grantBearingNotServedReason}). + * the reconcile holds it degraded ({@link grantBearingNotServedReason}); nor + * does `signed`, whose key is bound to a request signer instead + * (`./request-signing/index.ts`). */ function resolveInstanceAuth( - auth: Exclude | undefined, + auth: Exclude | undefined, secret: string | undefined, ): ResolvedConnectorAuth | undefined { if (!auth) return undefined; @@ -2337,7 +2355,27 @@ export class AutomationServicePlugin implements Plugin { } credential = resolution; } - const auth = resolveInstanceAuth(entry.auth, credential?.value); + // ADR-0143 D2.3, D4 — a `signed` instance's key is bound to the + // signer its scheme names, here, and the provider receives only + // the signer: the key never crosses into a provider package. A key + // that cannot sign at all is a configuration fault, like an + // unparseable retry policy below. + let auth: ResolvedConnectorAuth | undefined; + let requestSigner: ConnectorRequestSigner | undefined; + if (entry.auth?.type === 'signed') { + try { + requestSigner = createConnectorRequestSigner(name, entry.auth, credential?.value ?? ''); + } catch (err) { + fail( + `[Automation] connector instance '${name}' (provider '${provider}'): its signing key cannot sign ` + + `(ADR-0143 D2.3).`, + err, + ); + continue; + } + } else { + auth = resolveInstanceAuth(entry.auth, credential?.value); + } // The registry hands back RAW authored values (see // `DeclaredConnectorItem`), so the schema's defaults are applied @@ -2369,6 +2407,7 @@ export class AutomationServicePlugin implements Plugin { type: typeof entry.type === 'string' ? entry.type : 'api', providerConfig: entry.providerConfig ?? {}, auth, + ...(requestSigner === undefined ? {} : { requestSigner }), // ADR-0049 · #18975 — the declared resilience policy reaches the // factory. The built-in HTTP providers hand it to // `connectorFetchOptions()` → `resilientFetch()`; a custom @@ -2387,6 +2426,15 @@ export class AutomationServicePlugin implements Plugin { let materialization; try { materialization = await factory(providerCtx); + // ADR-0143 D2.3 / ADR-0049 — a `signed` instance is served only + // by a provider that says it signs every request. One that does + // not (it predates the member, or its upstream never signs, as + // MCP's does not) is a configuration fault, handled below like + // any factory's refusal — after its connection is let go. + if (requestSigner && entry.auth?.type === 'signed' && materialization.requestSignerApplied !== true) { + try { await materialization.close?.(); } catch { /* best-effort */ } + throw new Error(requestSignerNotAppliedReason(name, provider, entry.auth)); + } } catch (err) { // #3017 — an *operational* fault (upstream unreachable) degrades // the one instance instead of failing the run, in BOTH modes; diff --git a/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts b/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts new file mode 100644 index 00000000000..31fd585ffab --- /dev/null +++ b/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.3 vector rule: Alibaba Cloud publishes a complete worked example +// of its V3 signature, so the signer reproduces it byte for byte — every +// intermediate, not only the final value. Source: "V3 request structure and +// signature" (help.aliyun.com document 2593177; mirrored at +// https://www.alibabacloud.com/help/zh/sdk/product-overview/v3-request-structure-and-signature), +// section "Signature example" with its fixed parameter values. The key pair in +// it is the document's own placeholder (`YourAccessKeyId` / `YourAccessKeySecret`). + +import { describe, it, expect } from 'vitest'; +import { computeAliyunAcs3Signature, createAliyunAcs3Signer } from './aliyun-acs3-hmac-sha256.js'; +import { createConnectorRequestSigner } from './index.js'; + +const VENDOR_KEY_ID = 'YourAccessKeyId'; +const VENDOR_SECRET = 'YourAccessKeySecret'; +const VENDOR_DATE = '2023-10-26T10:22:32Z'; +const VENDOR_NONCE = '3156853299f313e23d1673dc12e1703d'; + +const vendorRequest = { + method: 'POST', + url: 'https://ecs.cn-shanghai.aliyuncs.com/?ImageId=win2019_1809_x64_dtc_zh-cn_40G_alibase_20230811.vhd&RegionId=cn-shanghai', + headers: { 'x-acs-action': 'RunInstances', 'x-acs-version': '2014-05-26' }, +}; + +const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; +const VENDOR_CANONICAL_REQUEST = [ + 'POST', + '/', + 'ImageId=win2019_1809_x64_dtc_zh-cn_40G_alibase_20230811.vhd&RegionId=cn-shanghai', + 'host:ecs.cn-shanghai.aliyuncs.com', + 'x-acs-action:RunInstances', + `x-acs-content-sha256:${EMPTY_SHA256}`, + 'x-acs-date:2023-10-26T10:22:32Z', + 'x-acs-signature-nonce:3156853299f313e23d1673dc12e1703d', + 'x-acs-version:2014-05-26', + '', + 'host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version', + EMPTY_SHA256, +].join('\n'); +const VENDOR_STRING_TO_SIGN = 'ACS3-HMAC-SHA256\n7ea06492da5221eba5297e897ce16e55f964061054b7695beedaac1145b1e259'; +const VENDOR_SIGNATURE = '06563a9e1b43f5dfe96b81484da74bceab24a1d853912eee15083a6f0f3283c0'; +const VENDOR_AUTHORIZATION = 'ACS3-HMAC-SHA256 Credential=YourAccessKeyId,' + + 'SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version,' + + `Signature=${VENDOR_SIGNATURE}`; + +describe("aliyun-acs3-hmac-sha256 — Alibaba Cloud's published worked example, byte for byte", () => { + const computed = computeAliyunAcs3Signature(vendorRequest, VENDOR_KEY_ID, VENDOR_SECRET, { + date: VENDOR_DATE, + nonce: VENDOR_NONCE, + }); + + it('reproduces the canonical request', () => { + expect(computed.canonicalRequest).toBe(VENDOR_CANONICAL_REQUEST); + }); + + it('reproduces the string to sign (the hashed canonical request)', () => { + expect(computed.stringToSign).toBe(VENDOR_STRING_TO_SIGN); + }); + + it('reproduces the signature and the Authorization header', () => { + expect(computed.signature).toBe(VENDOR_SIGNATURE); + expect(computed.headers).toEqual({ + authorization: VENDOR_AUTHORIZATION, + 'x-acs-date': VENDOR_DATE, + 'x-acs-signature-nonce': VENDOR_NONCE, + 'x-acs-content-sha256': EMPTY_SHA256, + }); + }); + + it('the bound signer answers the same bytes for the same instant and nonce', async () => { + const signer = createAliyunAcs3Signer('aliyun_ecs', VENDOR_KEY_ID, VENDOR_SECRET, { + now: () => new Date('2023-10-26T10:22:32.000Z'), + nonce: () => VENDOR_NONCE, + }); + const signature = await signer.sign(vendorRequest); + expect(signature.headers.authorization).toBe(VENDOR_AUTHORIZATION); + expect(signature.url).toBe(vendorRequest.url); + }); + + it('a wrong key does not produce the vendor signature', () => { + const wrong = computeAliyunAcs3Signature(vendorRequest, VENDOR_KEY_ID, 'NotYourAccessKeySecret', { + date: VENDOR_DATE, + nonce: VENDOR_NONCE, + }); + expect(wrong.stringToSign).toBe(VENDOR_STRING_TO_SIGN); + expect(wrong.signature).not.toBe(VENDOR_SIGNATURE); + expect(wrong.signature).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe('aliyun-acs3-hmac-sha256 — canonicalisation beyond the example', () => { + const at = { date: VENDOR_DATE, nonce: VENDOR_NONCE }; + + it('signs the body hash and the content type when the request carries them', () => { + const body = '{"RegionId":"cn-shanghai"}'; + const computed = computeAliyunAcs3Signature({ + method: 'post', + url: 'https://ecs.cn-shanghai.aliyuncs.com/', + headers: { 'Content-Type': 'application/json', 'X-Acs-Action': 'DescribeRegions', 'x-acs-version': '2014-05-26', accept: 'application/json' }, + body, + }, VENDOR_KEY_ID, VENDOR_SECRET, at); + const lines = computed.canonicalRequest.split('\n'); + const hashedPayload = lines[lines.length - 1]; + expect(lines[0]).toBe('POST'); + expect(lines).toContain('content-type:application/json'); + expect(lines).toContain('x-acs-action:DescribeRegions'); + expect(lines).not.toContain('accept:application/json'); + expect(hashedPayload).toMatch(/^[0-9a-f]{64}$/); + expect(hashedPayload).not.toBe(EMPTY_SHA256); + expect(computed.headers['x-acs-content-sha256']).toBe(hashedPayload); + expect(lines[lines.length - 2]).toBe('content-type;host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version'); + }); + + it('sorts the query and answers the URL in the canonical encoding the signature covers', () => { + const computed = computeAliyunAcs3Signature({ + method: 'GET', + url: "https://ecs.aliyuncs.com/a b/c*d?Z=1&Name=x+y&A=it's", + headers: {}, + }, VENDOR_KEY_ID, VENDOR_SECRET, at); + const [, path, query] = computed.canonicalRequest.split('\n'); + expect(path).toBe('/a%20b/c%2Ad'); + expect(query).toBe('A=it%27s&Name=x%20y&Z=1'); + expect(computed.url).toBe('https://ecs.aliyuncs.com/a%20b/c%2Ad?A=it%27s&Name=x%20y&Z=1'); + }); + + it('chooses a fresh nonce and the current instant on every request', async () => { + const signer = createAliyunAcs3Signer('aliyun_ecs', VENDOR_KEY_ID, VENDOR_SECRET); + const first = await signer.sign(vendorRequest); + const second = await signer.sign(vendorRequest); + expect(first.headers['x-acs-signature-nonce']).toMatch(/^[0-9a-f]{32}$/); + expect(first.headers['x-acs-signature-nonce']).not.toBe(second.headers['x-acs-signature-nonce']); + expect(first.headers['x-acs-date']).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); + expect(first.headers.authorization).not.toBe(second.headers.authorization); + }); +}); + +describe('createConnectorRequestSigner — the signer registry', () => { + const auth = { type: 'signed', scheme: 'aliyun-acs3-hmac-sha256', keyId: VENDOR_KEY_ID, credentialRef: 'ALIYUN_SECRET' } as const; + + it('binds the scheme the instance names to its key', async () => { + const signer = createConnectorRequestSigner('aliyun_ecs', auth, VENDOR_SECRET, { + now: () => new Date('2023-10-26T10:22:32Z'), + nonce: () => VENDOR_NONCE, + }); + expect([signer.connector, signer.scheme]).toEqual(['aliyun_ecs', 'aliyun-acs3-hmac-sha256']); + expect((await signer.sign(vendorRequest)).headers.authorization).toBe(VENDOR_AUTHORIZATION); + }); + + it('refuses an empty key before any request, without echoing a key', () => { + expect(() => createConnectorRequestSigner('aliyun_ecs', auth, '')).toThrow(/signing key its credentialRef names is empty/); + }); + + it('never carries the key in what it hands back', async () => { + const signer = createConnectorRequestSigner('aliyun_ecs', auth, VENDOR_SECRET); + const signature = await signer.sign(vendorRequest); + expect(JSON.stringify({ signer, signature })).not.toContain(VENDOR_SECRET); + }); +}); diff --git a/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.ts b/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.ts new file mode 100644 index 00000000000..6130f191763 --- /dev/null +++ b/packages/services/service-automation/src/request-signing/aliyun-acs3-hmac-sha256.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { createHash, createHmac, randomBytes } from 'node:crypto'; +import type { + ConnectorRequestSignature, + ConnectorRequestSigner, + ConnectorSignableRequest, +} from '@objectstack/spec/integration'; +import type { RequestSigningClock } from './clock.js'; + +/** + * `aliyun-acs3-hmac-sha256` — Alibaba Cloud's OpenAPI V3 request signature + * (ADR-0143 D2.3, Q4), implemented from the vendor's "V3 request structure and + * signature" document and pinned byte for byte against its published worked + * example (`aliyun-acs3-hmac-sha256.test.ts`). + * + * Every request carries: + * - `x-acs-date` (ISO 8601 UTC, seconds), `x-acs-signature-nonce` (single + * use) and `x-acs-content-sha256` (the hex SHA-256 of the body) — chosen + * here, per attempt; + * - `Authorization: ACS3-HMAC-SHA256 Credential=, + * SignedHeaders=,Signature=` over the string to + * sign `ACS3-HMAC-SHA256 \n hex(SHA-256(canonical request))`. + * + * The canonical request is the upper-case method, the canonical URI, the + * canonical query string, the canonical headers (`host`, `content-type` and + * every `x-acs-*` header, names lower-cased and sorted, values trimmed), the + * signed header names and the body hash, joined by newlines. The request names + * its API with the `x-acs-action` / `x-acs-version` headers the caller sends; + * they are signed as sent. + * + * Because the vendor canonicalises the path and the query with its own + * percent-encoding (RFC 3986: unreserved characters kept, space as `%20`, + * `*` as `%2A`), the signer answers the URL in that canonical spelling, so + * the query the upstream reads is byte for byte the one the signature covers + * (a WHATWG `URLSearchParams` would send a space as `+`). + * + * ⛔ The AccessKey secret lives only in the closure of the signer this module + * returns: no error, log line or returned value carries it. + */ + +export const ALIYUN_ACS3_ALGORITHM = 'ACS3-HMAC-SHA256'; + +/** RFC 3986 percent-encoding as the vendor specifies it: unreserved kept, everything else `%XX`. */ +function percentEncode(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); +} + +function sha256Hex(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +/** The path, one segment at a time, decoded from the URL's spelling and re-encoded canonically. */ +function canonicalUri(url: URL): string { + return url.pathname.split('/').map((segment) => percentEncode(decodeURIComponent(segment))).join('/') || '/'; +} + +/** Query parameters sorted by name (then value), each name and value canonically encoded. */ +function canonicalQueryString(url: URL): string { + const pairs = [...url.searchParams.entries()]; + pairs.sort(([ak, av], [bk, bv]) => (ak < bk ? -1 : ak > bk ? 1 : av < bv ? -1 : av > bv ? 1 : 0)); + return pairs.map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`).join('&'); +} + +/** Every intermediate of one signature — what the worked-example test pins, step by step. */ +export interface AliyunAcs3Computation { + readonly canonicalRequest: string; + readonly stringToSign: string; + readonly signature: string; + /** Headers the signer adds: `authorization` and the three `x-acs-*` values it chose. */ + readonly headers: Readonly>; + /** The URL in the canonical spelling the signature covers. */ + readonly url: string; +} + +/** + * Compute the ACS3-HMAC-SHA256 signature of `request` at the instant and nonce + * given. Pure: the same inputs give the same bytes, which is what lets the + * vendor's worked example pin it. + */ +export function computeAliyunAcs3Signature( + request: ConnectorSignableRequest, + keyId: string, + secret: string, + at: { readonly date: string; readonly nonce: string }, +): AliyunAcs3Computation { + const url = new URL(request.url); + const hashedPayload = sha256Hex(request.body ?? ''); + const own: Record = { + 'x-acs-date': at.date, + 'x-acs-signature-nonce': at.nonce, + 'x-acs-content-sha256': hashedPayload, + }; + const all = new Map(); + for (const [name, value] of Object.entries(request.headers)) all.set(name.toLowerCase(), value); + for (const [name, value] of Object.entries(own)) all.set(name, value); + // `fetch` derives `Host` from the URL and refuses to let a caller set it, + // so the URL is the only truth for the value the upstream checks. + all.set('host', url.host); + + const signedNames = [...all.keys()] + .filter((name) => name === 'host' || name === 'content-type' || name.startsWith('x-acs-')) + .sort(); + const canonicalHeaders = signedNames.map((name) => `${name}:${(all.get(name) ?? '').trim()}\n`).join(''); + const signedHeaders = signedNames.join(';'); + const path = canonicalUri(url); + const query = canonicalQueryString(url); + + const canonicalRequest = [ + request.method.toUpperCase(), + path, + query, + canonicalHeaders, + signedHeaders, + hashedPayload, + ].join('\n'); + const stringToSign = `${ALIYUN_ACS3_ALGORITHM}\n${sha256Hex(canonicalRequest)}`; + const signature = createHmac('sha256', secret).update(stringToSign, 'utf8').digest('hex'); + + return { + canonicalRequest, + stringToSign, + signature, + headers: { + ...own, + authorization: `${ALIYUN_ACS3_ALGORITHM} Credential=${keyId},SignedHeaders=${signedHeaders},Signature=${signature}`, + }, + url: `${url.origin}${path}${query ? `?${query}` : ''}`, + }; +} + +/** `x-acs-date`: ISO 8601 in UTC to the second, as the vendor's example spells it (`2023-10-26T10:22:32Z`). */ +function acsDate(now: Date): string { + return now.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** Bind an ACS3-HMAC-SHA256 signer to one instance's AccessKey. */ +export function createAliyunAcs3Signer( + connector: string, + keyId: string, + secret: string, + clock: RequestSigningClock = {}, +): ConnectorRequestSigner { + const now = clock.now ?? (() => new Date()); + const nonce = clock.nonce ?? (() => randomBytes(16).toString('hex')); + return { + connector, + scheme: 'aliyun-acs3-hmac-sha256', + async sign(request): Promise { + const { headers, url } = computeAliyunAcs3Signature(request, keyId, secret, { + date: acsDate(now()), + nonce: nonce(), + }); + return { headers, url }; + }, + }; +} diff --git a/packages/services/service-automation/src/request-signing/clock.ts b/packages/services/service-automation/src/request-signing/clock.ts new file mode 100644 index 00000000000..d8fbf1115a4 --- /dev/null +++ b/packages/services/service-automation/src/request-signing/clock.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two inputs of a request signature that are not the request or the key: + * the instant and the single-use nonce. Injected so a vendor's worked example + * can be reproduced byte for byte; a production signer takes the defaults (the + * wall clock, and random bytes from `node:crypto`). + */ +export interface RequestSigningClock { + readonly now?: () => Date; + readonly nonce?: () => string; +} diff --git a/packages/services/service-automation/src/request-signing/index.ts b/packages/services/service-automation/src/request-signing/index.ts new file mode 100644 index 00000000000..78edb7665b8 --- /dev/null +++ b/packages/services/service-automation/src/request-signing/index.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ConnectorInstanceSignedAuth, ConnectorRequestSigner } from '@objectstack/spec/integration'; +import { createAliyunAcs3Signer } from './aliyun-acs3-hmac-sha256.js'; +import type { RequestSigningClock } from './clock.js'; + +export type { RequestSigningClock } from './clock.js'; + +/** + * The signer registry (ADR-0143 D2.3, D4): bind a `signed` instance's key to + * the signer its `scheme` names. The host calls it once the instance's + * `credentialRef` has resolved, and hands the provider the signer — never the + * key. + * + * The switch is exhaustive over `ConnectorSigningSchemeSchema`'s members, so a + * scheme added to the spec without a signer here fails to compile: an enum + * member cannot ship ahead of its signer (ADR-0049). + * + * Throws when the key cannot sign at all (an empty one), before any request + * is made; never with the key in the message. + */ +export function createConnectorRequestSigner( + connector: string, + auth: ConnectorInstanceSignedAuth, + key: string, + clock?: RequestSigningClock, +): ConnectorRequestSigner { + if (key.length === 0) { + throw new Error(`connector instance '${connector}': the signing key its credentialRef names is empty`); + } + switch (auth.scheme) { + case 'aliyun-acs3-hmac-sha256': + return createAliyunAcs3Signer(connector, auth.keyId, key, clock); + default: { + const unsigned: never = auth.scheme; + throw new Error(`connector instance '${connector}': no signer implements scheme '${String(unsigned)}'`); + } + } +} diff --git a/packages/spec/api-surface/integration.json b/packages/spec/api-surface/integration.json index 8b39c7bb886..e2ced88683c 100644 --- a/packages/spec/api-surface/integration.json +++ b/packages/spec/api-surface/integration.json @@ -19,6 +19,7 @@ "ConnectorCredentialRequest (interface)", "ConnectorDescriptor (interface)", "ConnectorFetchPolicy (interface)", + "ConnectorHttpResult (interface)", "ConnectorInstanceAPIKeyAuth (type)", "ConnectorInstanceAPIKeyAuthSchema (const)", "ConnectorInstanceAppCredentialAuth (type)", @@ -37,6 +38,10 @@ "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema (const)", "ConnectorInstanceOAuth2ClientCredentialsAuth (type)", "ConnectorInstanceOAuth2ClientCredentialsAuthSchema (const)", + "ConnectorInstanceSignedAliyunAcs3Auth (type)", + "ConnectorInstanceSignedAliyunAcs3AuthSchema (const)", + "ConnectorInstanceSignedAuth (type)", + "ConnectorInstanceSignedAuthSchema (const)", "ConnectorMaterialization (interface)", "ConnectorMaterializationHandler (type)", "ConnectorOAuth2Grant (type)", @@ -45,9 +50,15 @@ "ConnectorParsed (type)", "ConnectorProviderContext (interface)", "ConnectorProviderFactory (type)", + "ConnectorRequestSignature (interface)", + "ConnectorRequestSigner (interface)", "ConnectorRetryStrategy (type)", "ConnectorRetryStrategySchema (const)", "ConnectorSchema (const)", + "ConnectorSignableRequest (interface)", + "ConnectorSignedResponse (interface)", + "ConnectorSigningScheme (type)", + "ConnectorSigningSchemeSchema (const)", "ConnectorState (type)", "ConnectorType (type)", "ConnectorTypeSchema (const)", @@ -63,6 +74,7 @@ "connectorFetchOptions (function)", "defineConnector (function)", "isConnectorCredentialRejected (function)", - "isConnectorUpstreamUnavailable (function)" + "isConnectorUpstreamUnavailable (function)", + "readConnectorResponse (function)" ] } diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index f6e79542bd1..a7d868e4eb8 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -67,6 +67,10 @@ "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:scopes", "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:tokenUrl", "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:type", + "integration/ConnectorInstanceSignedAliyunAcs3Auth:credentialRef", + "integration/ConnectorInstanceSignedAliyunAcs3Auth:keyId", + "integration/ConnectorInstanceSignedAliyunAcs3Auth:scheme", + "integration/ConnectorInstanceSignedAliyunAcs3Auth:type", "integration/DeclarativeConnectorEntry:_lock", "integration/DeclarativeConnectorEntry:_lockDocsUrl", "integration/DeclarativeConnectorEntry:_lockReason", diff --git a/packages/spec/declaration-map/integration.json b/packages/spec/declaration-map/integration.json index b760f20ca37..46b092a4cc4 100644 --- a/packages/spec/declaration-map/integration.json +++ b/packages/spec/declaration-map/integration.json @@ -27,11 +27,17 @@ "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema": "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", "ConnectorInstanceOAuth2ClientCredentialsAuth": "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", "ConnectorInstanceOAuth2ClientCredentialsAuthSchema": "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "ConnectorInstanceSignedAliyunAcs3Auth": "integration/ConnectorInstanceSignedAliyunAcs3Auth", + "ConnectorInstanceSignedAliyunAcs3AuthSchema": "integration/ConnectorInstanceSignedAliyunAcs3Auth", + "ConnectorInstanceSignedAuth": "integration/ConnectorInstanceSignedAuth", + "ConnectorInstanceSignedAuthSchema": "integration/ConnectorInstanceSignedAuth", "ConnectorOAuth2Grant": "integration/ConnectorOAuth2Grant", "ConnectorOAuth2GrantSchema": "integration/ConnectorOAuth2Grant", "ConnectorRetryStrategy": "integration/ConnectorRetryStrategy", "ConnectorRetryStrategySchema": "integration/ConnectorRetryStrategy", "ConnectorSchema": "integration/Connector", + "ConnectorSigningScheme": "integration/ConnectorSigningScheme", + "ConnectorSigningSchemeSchema": "integration/ConnectorSigningScheme", "ConnectorType": "integration/ConnectorType", "ConnectorTypeSchema": "integration/ConnectorType", "DeclarativeConnectorEntry": "integration/DeclarativeConnectorEntry", diff --git a/packages/spec/docs/SYNC_ARCHITECTURE.md b/packages/spec/docs/SYNC_ARCHITECTURE.md index 4ec27377746..497b64bacd4 100644 --- a/packages/spec/docs/SYNC_ARCHITECTURE.md +++ b/packages/spec/docs/SYNC_ARCHITECTURE.md @@ -350,7 +350,10 @@ The runtime `authentication` takes the four static kinds: A token obtained from a token endpoint (OAuth 2.0 `authorization_code` or `client_credentials`, or a Feishu, DingTalk or WeCom app credential) is declared on a provider-bound instance's `auth` as `oauth2` or `app-credential`, with the -secret behind a `credentialRef` (ADR-0143). See +secret behind a `credentialRef` (ADR-0143). An upstream that authenticates +each request by a signature over the request itself is declared as `signed`, +with a closed `scheme`, the public `keyId` and the signing key behind a +`credentialRef`; the platform signs every attempt (ADR-0143 D2.3). See [Connectors](../../../content/docs/automation/connectors.mdx). ### Best Practices diff --git a/packages/spec/export-origins/integration.json b/packages/spec/export-origins/integration.json index ccfaf4cf01b..9f3484e855d 100644 --- a/packages/spec/export-origins/integration.json +++ b/packages/spec/export-origins/integration.json @@ -19,6 +19,7 @@ "ConnectorCredentialRequest": "src/integration/connector-provider.ts#ConnectorCredentialRequest (interface)", "ConnectorDescriptor": "src/integration/connector-descriptor.ts#ConnectorDescriptor (interface)", "ConnectorFetchPolicy": "src/integration/connector-fetch-policy.ts#ConnectorFetchPolicy (interface)", + "ConnectorHttpResult": "src/integration/connector-fetch-policy.ts#ConnectorHttpResult (interface)", "ConnectorInstanceAPIKeyAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAPIKeyAuth (type)", "ConnectorInstanceAPIKeyAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceAPIKeyAuthSchema (const)", "ConnectorInstanceAppCredentialAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAppCredentialAuth (type)", @@ -37,6 +38,10 @@ "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2AuthorizationCodeAuthSchema (const)", "ConnectorInstanceOAuth2ClientCredentialsAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2ClientCredentialsAuth (type)", "ConnectorInstanceOAuth2ClientCredentialsAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2ClientCredentialsAuthSchema (const)", + "ConnectorInstanceSignedAliyunAcs3Auth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAliyunAcs3Auth (type)", + "ConnectorInstanceSignedAliyunAcs3AuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAliyunAcs3AuthSchema (const)", + "ConnectorInstanceSignedAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAuth (type)", + "ConnectorInstanceSignedAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceSignedAuthSchema (const)", "ConnectorMaterialization": "src/integration/connector-provider.ts#ConnectorMaterialization (interface)", "ConnectorMaterializationHandler": "src/integration/connector-provider.ts#ConnectorMaterializationHandler (type)", "ConnectorOAuth2Grant": "src/shared/connector-auth.zod.ts#ConnectorOAuth2Grant (type)", @@ -45,9 +50,15 @@ "ConnectorParsed": "src/integration/connector.zod.ts#ConnectorParsed (type)", "ConnectorProviderContext": "src/integration/connector-provider.ts#ConnectorProviderContext (interface)", "ConnectorProviderFactory": "src/integration/connector-provider.ts#ConnectorProviderFactory (type)", + "ConnectorRequestSignature": "src/integration/connector-provider.ts#ConnectorRequestSignature (interface)", + "ConnectorRequestSigner": "src/integration/connector-provider.ts#ConnectorRequestSigner (interface)", "ConnectorRetryStrategy": "src/integration/connector.zod.ts#ConnectorRetryStrategy (type)", "ConnectorRetryStrategySchema": "src/integration/connector.zod.ts#ConnectorRetryStrategySchema (const)", "ConnectorSchema": "src/integration/connector.zod.ts#ConnectorSchema (const)", + "ConnectorSignableRequest": "src/integration/connector-provider.ts#ConnectorSignableRequest (interface)", + "ConnectorSignedResponse": "src/integration/connector-provider.ts#ConnectorSignedResponse (interface)", + "ConnectorSigningScheme": "src/shared/connector-auth.zod.ts#ConnectorSigningScheme (type)", + "ConnectorSigningSchemeSchema": "src/shared/connector-auth.zod.ts#ConnectorSigningSchemeSchema (const)", "ConnectorState": "src/integration/connector-descriptor.ts#ConnectorState (type)", "ConnectorType": "src/integration/connector.zod.ts#ConnectorType (type)", "ConnectorTypeSchema": "src/integration/connector.zod.ts#ConnectorTypeSchema (const)", @@ -63,6 +74,7 @@ "connectorFetchOptions": "src/integration/connector-fetch-policy.ts#connectorFetchOptions (function)", "defineConnector": "src/integration/connector.zod.ts#defineConnector (function)", "isConnectorCredentialRejected": "src/integration/connector-provider-errors.ts#isConnectorCredentialRejected (function)", - "isConnectorUpstreamUnavailable": "src/integration/connector-provider-errors.ts#isConnectorUpstreamUnavailable (function)" + "isConnectorUpstreamUnavailable": "src/integration/connector-provider-errors.ts#isConnectorUpstreamUnavailable (function)", + "readConnectorResponse": "src/integration/connector-fetch-policy.ts#readConnectorResponse (function)" } } diff --git a/packages/spec/json-schema.manifest/integration.json b/packages/spec/json-schema.manifest/integration.json index 2299d9aca67..8a9725be706 100644 --- a/packages/spec/json-schema.manifest/integration.json +++ b/packages/spec/json-schema.manifest/integration.json @@ -15,8 +15,11 @@ "integration/ConnectorInstanceOAuth2Auth", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "integration/ConnectorInstanceSignedAliyunAcs3Auth", + "integration/ConnectorInstanceSignedAuth", "integration/ConnectorOAuth2Grant", "integration/ConnectorRetryStrategy", + "integration/ConnectorSigningScheme", "integration/ConnectorType", "integration/DeclarativeConnectorEntry", "integration/RetryConfig" diff --git a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts index 96fd30d66ea..0b46b844f1d 100644 --- a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts +++ b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts @@ -181,8 +181,8 @@ describe('ADR-0143 D2.2 — `app-credential` instance auth', () => { }); describe('ConnectorInstanceAuthSchema — closed', () => { - it('refuses an unknown kind, and `signed` is not a member until its signers land (ADR-0143 D2.3)', () => { - for (const type of ['signed', 'oauth1']) { + it('refuses an unknown kind (`signed` is pinned in connector-auth-signed.test.ts)', () => { + for (const type of ['oauth1', 'hmac']) { const result = ConnectorInstanceAuthSchema.safeParse({ type, credentialRef: 'X' }); expect(result.success).toBe(false); expect(result.error!.issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual(['invalid_union @ type']); diff --git a/packages/spec/src/integration/connector-auth-signed.test.ts b/packages/spec/src/integration/connector-auth-signed.test.ts new file mode 100644 index 00000000000..604133afe6b --- /dev/null +++ b/packages/spec/src/integration/connector-auth-signed.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.3 — the `signed` kind of declarative connector instance auth, as +// an author meets it at the publish door (`DeclarativeConnectorEntrySchema`, +// which both authoring doors parse). Every refusal is pinned by its issue +// `code` and `path`; every member has a CONTROL accept, so a refusal that fired +// on everything could not pass. + +import { describe, it, expect } from 'vitest'; +import { + DeclarativeConnectorEntrySchema, + ConnectorInstanceAuthSchema, + ConnectorInstanceSignedAuthSchema, + ConnectorSigningSchemeSchema, +} from './connector.zod'; + +const entry = (auth: unknown, over: Record = {}) => ({ + name: 'aliyun_ecs', + label: 'Aliyun ECS', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://ecs.cn-shanghai.aliyuncs.com' }, + auth, + ...over, +}); + +const aliyun = { + type: 'signed', + scheme: 'aliyun-acs3-hmac-sha256', + keyId: 'YourAccessKeyId', + credentialRef: 'ALIYUN_ACCESS_KEY_SECRET', +}; + +/** The issues of a refused parse, as `code @ path` — what each refusal pins. */ +function refused(input: unknown) { + const result = DeclarativeConnectorEntrySchema.safeParse(input); + expect(result.success, 'the entry must be refused').toBe(false); + return result.error!.issues; +} + +const located = (input: unknown) => refused(input).map((i) => `${i.code} @ ${i.path.join('.')}`); + +describe('ADR-0143 D2.3 — `signed` instance auth', () => { + it('CONTROL: accepts aliyun-acs3-hmac-sha256 with a key id and the secret as a reference, on rest and openapi', () => { + expect(DeclarativeConnectorEntrySchema.parse(entry(aliyun)).auth).toEqual(aliyun); + const openapi = { provider: 'openapi', providerConfig: { spec: './ecs-openapi.json' } }; + expect(DeclarativeConnectorEntrySchema.parse(entry(aliyun, openapi)).auth).toEqual(aliyun); + }); + + it('refuses an unknown scheme at `auth.scheme`, naming the scheme it got and the ones that ship', () => { + for (const scheme of ['aws-sigv4', 'wechatpay-rsa2048', 'hmac-sha256-generic', 'alipay-rsa2']) { + const issues = refused(entry({ ...aliyun, scheme })); + expect(issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual(['invalid_union @ auth.scheme']); + expect(issues[0]!.message).toContain(`"${scheme}"`); + expect(issues[0]!.message).toContain("'aliyun-acs3-hmac-sha256'"); + } + }); + + it('refuses a missing scheme, listing the ones that ship', () => { + const { scheme: _s, ...noScheme } = aliyun; + const issues = refused(entry(noScheme)); + expect(issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual(['invalid_union @ auth.scheme']); + expect(issues[0]!.message).toContain('needs a `scheme`'); + }); + + it('refuses each inline-secret spelling by name, pointing at credentialRef', () => { + for (const key of ['secret', 'privateKey', 'secretKey', 'accessKeySecret']) { + const issues = refused(entry({ ...aliyun, [key]: 'inline' })); + expect(issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', [key]], + ]); + expect(issues[0]!.message).toContain('credentialRef'); + } + }); + + it('refuses an authored signature: the signer computes one per request', () => { + const issues = refused(entry({ ...aliyun, signature: 'abc' })); + expect(issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['signature']], + ]); + expect(issues[0]!.message).toContain('computed by the platform for every request'); + }); + + it("answers Alibaba Cloud's own word for the key id with `keyId`", () => { + const { keyId, ...rest } = aliyun; + const issues = refused(entry({ ...rest, accessKeyId: keyId })); + const unknown = issues.find((i) => i.code === 'unrecognized_keys')!; + expect(unknown.message).toContain('keyId'); + }); + + it('requires the key id and the secret reference', () => { + const { keyId: _k, ...noKeyId } = aliyun; + expect(located(entry(noKeyId))).toEqual(['invalid_type @ auth.keyId']); + const { credentialRef: _r, ...noSecret } = aliyun; + expect(located(entry(noSecret))).toEqual(['invalid_type @ auth.credentialRef']); + }); + + it("refuses `signed` on `provider: 'mcp'` at `auth.type`: an MCP server is never signed per request", () => { + const mcp = { provider: 'mcp', providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com/mcp' } } }; + const issues = refused(entry(aliyun, mcp)); + expect(issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual(['custom @ auth.type']); + expect(issues[0]!.message).toContain("`provider: 'openapi'`"); + }); + + it('the scheme enum lists exactly the union members, so the closed list cannot drift from the shapes', () => { + const members = ConnectorInstanceSignedAuthSchema.options.map((o) => o.shape.scheme.value); + expect([...members].sort()).toEqual([...ConnectorSigningSchemeSchema.options].sort()); + }); + + it('is a member of the closed instance-auth union', () => { + expect(ConnectorInstanceAuthSchema.safeParse(aliyun).success).toBe(true); + }); +}); diff --git a/packages/spec/src/integration/connector-fetch-policy.test.ts b/packages/spec/src/integration/connector-fetch-policy.test.ts index 9b65ce193fe..27adb20588d 100644 --- a/packages/spec/src/integration/connector-fetch-policy.test.ts +++ b/packages/spec/src/integration/connector-fetch-policy.test.ts @@ -1,7 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { connectorFetchOptions, type ConnectorFetchPolicy } from './connector-fetch-policy'; +import { connectorFetchOptions, readConnectorResponse, type ConnectorFetchPolicy } from './connector-fetch-policy'; +import type { ConnectorRequestSigner, ConnectorSignableRequest } from './connector-provider'; +import { resilientFetch } from '../shared/resilient-fetch'; describe('connectorFetchOptions', () => { it('leaves the wrapper on its own defaults when nothing is declared', () => { @@ -109,3 +111,123 @@ describe('connectorFetchOptions', () => { expect(() => connectorFetchOptions({ retryConfig: { maxAttempts: 99 } })).toThrow(); }); }); + +describe('connectorFetchOptions — a signed instance is signed per attempt (ADR-0143 D2.3)', () => { + /** A signer double: each call answers a fresh nonce, and records what it was asked to sign. */ + function countingSigner(over: Partial = {}) { + const seen: ConnectorSignableRequest[] = []; + const signer: ConnectorRequestSigner = { + connector: 'vendor', + scheme: 'test-scheme', + async sign(request) { + seen.push(request); + return { headers: { Authorization: `Sig n${seen.length}`, 'X-Nonce': `n${seen.length}` } }; + }, + ...over, + }; + return { signer, seen }; + } + + /** A fetch double answering the queued statuses in order, recording each call. */ + function scriptedFetch(statuses: number[]) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const impl = (async (input: string | URL | Request, init: RequestInit = {}) => { + calls.push({ url: String(input), init }); + return new Response('{}', { status: statuses.shift() ?? 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + return { impl, calls }; + } + + it('CONTROL: without a signer the caller\'s fetch is handed through untouched', () => { + const { impl } = scriptedFetch([]); + expect(connectorFetchOptions({}, { fetchImpl: impl }).fetchImpl).toBe(impl); + }); + + it('signs every attempt afresh, so a retry never replays a signature', async () => { + const { signer, seen } = countingSigner(); + const { impl, calls } = scriptedFetch([503, 200]); + const opts = connectorFetchOptions({}, { fetchImpl: impl, sleep: async () => {} }, signer); + const res = await resilientFetch('https://api.example.com/v1/items?q=1', { + method: 'post', + headers: { 'Content-Type': 'application/json' }, + body: '{"a":1}', + }, opts); + expect(res.status).toBe(200); + expect(seen).toHaveLength(2); + expect(calls.map((c) => (c.init.headers as Record)['x-nonce'])).toEqual(['n1', 'n2']); + expect(seen[0]).toEqual({ + method: 'POST', + url: 'https://api.example.com/v1/items?q=1', + headers: { 'content-type': 'application/json' }, + body: '{"a":1}', + }); + }); + + it('a signer header replaces a same-named request header, whatever its case', async () => { + const { signer } = countingSigner(); + const { impl, calls } = scriptedFetch([200]); + await resilientFetch('https://api.example.com/', { headers: { authorization: 'Bearer stale' } }, + connectorFetchOptions({}, { fetchImpl: impl }, signer)); + expect(calls[0]!.init.headers).toEqual({ authorization: 'Sig n1', 'x-nonce': 'n1' }); + }); + + it('sends the URL a signer answers, when its scheme signs a canonical query', async () => { + const { signer } = countingSigner({ + async sign() { return { headers: {}, url: 'https://api.example.com/?a=x%20y' }; }, + }); + const { impl, calls } = scriptedFetch([200]); + await resilientFetch('https://api.example.com/?a=x+y', {}, connectorFetchOptions({}, { fetchImpl: impl }, signer)); + expect(calls[0]!.url).toBe('https://api.example.com/?a=x%20y'); + }); + + it('refuses a body that is not text, and a Request object, rather than sign something else', async () => { + const { signer } = countingSigner(); + const { impl, calls } = scriptedFetch([]); + const opts = connectorFetchOptions({}, { fetchImpl: impl, sleep: async () => {} }, signer); + await expect(opts.fetchImpl!('https://api.example.com/', { method: 'POST', body: new Uint8Array([1]) })) + .rejects.toThrow(/body must be text/); + await expect(opts.fetchImpl!(new Request('https://api.example.com/'))).rejects.toThrow(/not as a Request object/); + expect(calls).toHaveLength(0); + }); +}); + +describe('readConnectorResponse', () => { + const json = (body: string, status = 200) => + new Response(body, { status, headers: { 'content-type': 'application/json' } }); + + it('CONTROL: parses JSON and passes text through, as the HTTP connectors always did', async () => { + expect(await readConnectorResponse(json('{"a":1}'))).toEqual({ status: 200, ok: true, body: { a: 1 } }); + expect(await readConnectorResponse(new Response('plain', { status: 404 }))) + .toEqual({ status: 404, ok: false, body: 'plain' }); + }); + + it('CONTROL: a signer without response verification reads exactly as no signer', async () => { + const signer: ConnectorRequestSigner = { connector: 'v', scheme: 's', sign: async () => ({ headers: {} }) }; + expect(await readConnectorResponse(json('{"a":1}'), signer)).toEqual({ status: 200, ok: true, body: { a: 1 } }); + }); + + it('runs verifyResponse over the raw body before parsing', async () => { + const verified: string[] = []; + const signer: ConnectorRequestSigner = { + connector: 'v', + scheme: 's', + sign: async () => ({ headers: {} }), + async verifyResponse(response) { + verified.push(`${response.status} ${response.headers.get('x-sig')} ${response.body}`); + }, + }; + const res = new Response('{ "a" : 1 }', { headers: { 'content-type': 'application/json', 'x-sig': 'ok' } }); + expect(await readConnectorResponse(res, signer)).toEqual({ status: 200, ok: true, body: { a: 1 } }); + expect(verified).toEqual(['200 ok { "a" : 1 }']); + }); + + it('a response that fails verification throws, and its body is never returned', async () => { + const signer: ConnectorRequestSigner = { + connector: 'v', + scheme: 's', + sign: async () => ({ headers: {} }), + async verifyResponse() { throw new Error('response signature does not verify'); }, + }; + await expect(readConnectorResponse(json('{"forged":true}'), signer)).rejects.toThrow(/does not verify/); + }); +}); diff --git a/packages/spec/src/integration/connector-fetch-policy.ts b/packages/spec/src/integration/connector-fetch-policy.ts index 7b42b4c5c01..a370a81b686 100644 --- a/packages/spec/src/integration/connector-fetch-policy.ts +++ b/packages/spec/src/integration/connector-fetch-policy.ts @@ -2,6 +2,7 @@ import type { ResilientFetchOptions } from '../shared/resilient-fetch'; import { RetryConfigSchema, type RetryConfig } from './connector.zod'; +import type { ConnectorRequestSigner } from './connector-provider'; /** * Re-exported so the type this module's return value has is nameable from the @@ -73,6 +74,20 @@ export type { ResilientFetchOptions } from '../shared/resilient-fetch'; * "restore" it by aliasing a connect deadline onto `timeoutMs`: two keys that * silently mean one thing is the declared-not-enforced shape this mapping * exists to remove, and it is the shape the retirement just closed. + * + * ## A `signed` instance is signed per ATTEMPT, here (ADR-0143 D2.3) + * + * `resilientFetch` re-sends a request on a retryable status or a network + * error. A request signature carries a timestamp and a nonce, and an upstream + * refuses one it has seen (Alibaba Cloud's `x-acs-signature-nonce` is + * single-use), so a signature computed once and replayed on the retry would + * turn every transient blip into an authentication failure. Handing a + * {@link ConnectorRequestSigner} to {@link connectorFetchOptions} wraps the + * fetch the wrapper calls, so EVERY attempt is signed fresh — and this stays + * the one site that does it for both built-in HTTP connectors, rather than one + * copy per connector package. {@link readConnectorResponse} is the matching + * one site for the response half: a scheme's `verifyResponse` runs over the + * raw body before anything is parsed or returned. */ /** The slice of a {@link Connector} this mapping reads. */ @@ -97,8 +112,10 @@ export interface ConnectorFetchPolicy { export function connectorFetchOptions( policy: ConnectorFetchPolicy, base: Pick = {}, + signer?: ConnectorRequestSigner, ): ResilientFetchOptions { const opts: ResilientFetchOptions = { ...base }; + if (signer) opts.fetchImpl = signingFetch(signer, base.fetchImpl); if (policy.requestTimeoutMs !== undefined) opts.timeoutMs = policy.requestTimeoutMs; if (policy.retryConfig === undefined) return opts; @@ -118,3 +135,75 @@ export function connectorFetchOptions( opts.retryableStatus = (status: number): boolean => codes.includes(status); return opts; } + +/** + * A fetch that signs each call with `signer` before sending it through + * `fetchImpl` (the global `fetch` when absent). `resilientFetch` calls it once + * per attempt, which is what makes a retry carry a fresh signature. + * + * Only the URL-plus-init form is signed: a `Request` object carries its own + * method, headers and body where this wrapper cannot see them, and a body that + * is not text cannot be canonicalised — both are refused rather than sent with + * a signature over something else. + */ +function signingFetch(signer: ConnectorRequestSigner, fetchImpl: typeof fetch | undefined): typeof fetch { + return async (input, init = {}) => { + const doFetch = fetchImpl ?? (globalThis.fetch as typeof fetch | undefined); + if (!doFetch) { + throw new Error('connectorFetchOptions: no fetch implementation (pass fetchImpl or run on a fetch-capable runtime)'); + } + if (typeof input !== 'string' && !(input instanceof URL)) { + throw new Error(`connector '${signer.connector}': a signed request is sent as a URL and its init, not as a Request object`); + } + const body = init.body ?? undefined; + if (body !== undefined && typeof body !== 'string') { + throw new Error(`connector '${signer.connector}': a signed request's body must be text, so the signature can cover it`); + } + const url = typeof input === 'string' ? input : input.href; + const headers: Record = {}; + new Headers(init.headers).forEach((value, name) => { headers[name] = value; }); + const signature = await signer.sign({ + method: (init.method ?? 'GET').toUpperCase(), + url, + headers: { ...headers }, + ...(body === undefined ? {} : { body }), + }); + // `Headers` hands names back lower-cased, so lower-casing the signer's + // makes a signer header REPLACE a same-named request header, never sit + // beside it. + for (const [name, value] of Object.entries(signature.headers)) headers[name.toLowerCase()] = value; + return doFetch(signature.url ?? url, { ...init, headers }); + }; +} + +/** What a built-in HTTP connector's action answers: the upstream's status, and its body parsed when JSON. */ +export interface ConnectorHttpResult extends Record { + status: number; + ok: boolean; + body: unknown; +} + +/** + * Read a connector response into {@link ConnectorHttpResult} — the body parsed + * when the upstream advertises JSON, its text otherwise, so a non-JSON + * endpoint does not throw. + * + * With a `signer` whose scheme verifies responses, the raw body is read first + * and handed to `verifyResponse` before anything is parsed: a response that + * fails verification throws here, and its body never reaches the flow. + * Without one, this is exactly the read both built-in HTTP connectors always + * made. + */ +export async function readConnectorResponse( + response: Response, + signer?: ConnectorRequestSigner, +): Promise { + const json = (response.headers.get('content-type') ?? '').includes('application/json'); + if (!signer?.verifyResponse) { + const body: unknown = json ? await response.json() : await response.text(); + return { status: response.status, ok: response.ok, body }; + } + const raw = await response.text(); + await signer.verifyResponse({ status: response.status, headers: response.headers, body: raw }); + return { status: response.status, ok: response.ok, body: json ? JSON.parse(raw) as unknown : raw }; +} diff --git a/packages/spec/src/integration/connector-provider.ts b/packages/spec/src/integration/connector-provider.ts index 7c11b492586..01b3a68a442 100644 --- a/packages/spec/src/integration/connector-provider.ts +++ b/packages/spec/src/integration/connector-provider.ts @@ -105,6 +105,89 @@ export interface ConnectorCredentialHandle { refresh(request?: ConnectorCredentialRequest): Promise; } +// ============================================================================ +// Per-request signer (ADR-0143 D2.3, D4) +// +// A `signed` instance has no token: every outgoing request is signed with the +// key its `credentialRef` names, by the vendor's canonical-request algorithm. +// ADR-0143 D4 answers such an instance per dispatch with "a signer bound to its +// key", so the key never crosses this seam — the host resolves the reference, +// binds a signer to it, and hands the provider only the signer. A provider asks +// it on EVERY attempt it sends, retries included: a signature carries a +// timestamp and a nonce, and an upstream refuses one replayed. +// +// The seam carries the optional response-verification step from the start +// (ADR-0143 Q4 condition 2): a scheme whose vendor signs its responses +// implements `verifyResponse`, and a provider runs it over the raw body before +// it parses or returns anything. +// +// ⛔ Nothing here logs, stores or echoes a signature or the key behind it. +// ============================================================================ + +/** The request a {@link ConnectorRequestSigner} signs — exactly what is sent. */ +export interface ConnectorSignableRequest { + /** The HTTP method, upper case. */ + readonly method: string; + /** The absolute URL, query string included, as it would be sent. */ + readonly url: string; + /** The request's own headers, as they would be sent, before the signer's. */ + readonly headers: Readonly>; + /** The body as sent, or `undefined` when the request carries none. */ + readonly body?: string; +} + +/** What a signer adds to one request. */ +export interface ConnectorRequestSignature { + /** + * Headers to set on the request, each replacing any header of the same name + * (compared case-insensitively): the signature itself and every value the + * scheme signs that the signer chose (a timestamp, a nonce, a body digest). + */ + readonly headers: Readonly>; + /** + * The URL to send in place of the request's own, when the scheme signs the + * query string in a canonical encoding: the same resource and the same + * parameters, spelled the way the signature covers them. Absent ⇒ the + * request's own URL is sent. + */ + readonly url?: string; +} + +/** The response a verifying signer checks, its body read raw before any parse. */ +export interface ConnectorSignedResponse { + readonly status: number; + readonly headers: { get(name: string): string | null }; + /** The body exactly as received. */ + readonly body: string; +} + +/** + * A request signer bound to one connector instance's key (ADR-0143 D2.3, D4). + * A provider factory receives one as {@link ConnectorProviderContext.requestSigner} + * and applies it to every upstream request it sends. + */ +export interface ConnectorRequestSigner { + /** The connector instance this signer signs for. */ + readonly connector: string; + /** The scheme it implements — a member of `ConnectorSigningSchemeSchema`. */ + readonly scheme: string; + /** + * Sign one request. Called once per attempt, so a retried request carries a + * fresh signature. Throws when the request cannot be signed (a body that is + * not text); the request is then not sent. + */ + sign(request: ConnectorSignableRequest): Promise; + /** + * Verify the upstream's signature over a response, for a scheme whose vendor + * signs its responses; absent for a scheme whose vendor does not. Resolves + * when the response is authentic and throws when it is not — the response + * is then treated as the dispatch's failure and its body is never returned. + * Which responses the vendor signs (every status, or 2xx only) is the + * scheme's to decide, so a provider passes every response. + */ + verifyResponse?(response: ConnectorSignedResponse): Promise; +} + /** * A single materialized connector action handler. Receives the input the * `connector_action` node mapped from the flow, ignores the dispatch context @@ -138,6 +221,17 @@ export interface ConnectorMaterialization { * `rejected` credential stays so until it is set again. */ credentialVerified?: boolean; + /** + * `true` when the factory applies {@link ConnectorProviderContext.requestSigner} + * to every upstream request its handlers send (and runs its + * `verifyResponse`, when present, on every response). The host serves a + * `signed` instance only on a materialization that says so: a factory that + * ignores the member — one that predates it, or a provider whose upstream + * does not sign, such as MCP — would otherwise call the upstream unsigned, + * a declared-but-unenforced credential (ADR-0049). Leave it unset when the + * context carried no signer. + */ + requestSignerApplied?: boolean; } /** @@ -186,6 +280,17 @@ export interface ConnectorProviderContext { * a clear message rather than calling the upstream unauthenticated. */ readonly credentialHandle?: ConnectorCredentialHandle; + /** + * The request signer of a `signed` instance (ADR-0143 D2.3, D4), bound to the + * key the entry's `credentialRef` resolved to — the key itself never reaches + * the factory. The host sets it for that kind, and only for it; {@link auth} + * is then `undefined`. A factory signs every attempt it sends with it (a + * retry included), runs its `verifyResponse` over every response before + * parsing it, and says so by returning + * {@link ConnectorMaterialization.requestSignerApplied}; the host refuses to + * serve a `signed` instance on a materialization that does not. + */ + readonly requestSigner?: ConnectorRequestSigner; /** * The entry's declared retry policy, **resolved** — the host parses it so the * schema's defaults (`maxAttempts: 3`, `[408, 429, 500, 502, 503, 504]`, …) diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index cc6fa7e8b49..0b8aa5ba2c5 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1251,6 +1251,20 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => }); } } + // ADR-0143 D2.3 — a `signed` instance is signed by the HTTP providers + // (`openapi`, `rest`), which apply the host's request signer to every + // request. An MCP server authenticates its clients with a token, never a + // per-request signature, so the mcp provider has no signer to apply: the + // instance would be called unsigned. Refused here, where the provider is + // known; the host refuses any other provider that does not apply the + // signer when it materializes the instance. + if (entry.auth?.type === 'signed' && entry.provider === 'mcp') { + ctx.addIssue({ + code: 'custom', + path: ['auth', 'type'], + message: `Connector instance '${entry.name}' declares \`auth: { type: 'signed' }\` on \`provider: 'mcp'\`. An MCP server authenticates its clients with a token, not a per-request signature, so the request would go out unsigned. Declare the signing vendor's API through \`provider: 'openapi'\` or \`provider: 'rest'\`, or give the MCP instance the auth its server asks for (ADR-0143 D2.3).`, + }); + } }), CONNECTOR_RETIRED_KEY_RESIDUE), ); @@ -1272,6 +1286,9 @@ export { ConnectorInstanceAppCredentialAuthSchema, ConnectorOAuth2GrantSchema, ConnectorAppCredentialVendorSchema, + ConnectorInstanceSignedAuthSchema, + ConnectorInstanceSignedAliyunAcs3AuthSchema, + ConnectorSigningSchemeSchema, } from '../shared/connector-auth.zod'; export type { ConnectorInstanceAuth, @@ -1290,4 +1307,7 @@ export type { ConnectorInstanceAppCredentialAuth, ConnectorOAuth2Grant, ConnectorAppCredentialVendor, + ConnectorInstanceSignedAuth, + ConnectorInstanceSignedAliyunAcs3Auth, + ConnectorSigningScheme, } from '../shared/connector-auth.zod'; diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 633172658d0..21e42b95801 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -128,7 +128,9 @@ export type ConnectorAuthConfigParsed = z.infer z.object({ export type ConnectorInstanceBasicAuth = z.input; /** - * The guidance an inline secret meets on a grant-bearing instance kind: the - * strict shape refuses the key by name and sends the author to - * `credentialRef` (ADR-0143 D3 / Acceptance: "an inline secret on any variant - * is refused at publish with a located error"). + * The guidance an inline secret meets on an ADR-0143 instance kind (`oauth2`, + * `app-credential`, `signed`): the strict shape refuses the key by name and + * sends the author to `credentialRef` (ADR-0143 D3 / Acceptance: "an inline + * secret on any variant is refused at publish with a located error"). */ function inlineSecretGuidance(kind: string, secret: string): string { return `Not authorable on \`auth: { type: '${kind}' }\`. The ${secret} is a credential, and a connector ` @@ -376,6 +380,87 @@ export const ConnectorInstanceAppCredentialAuthSchema = lazySchema(() => strictO })); export type ConnectorInstanceAppCredentialAuth = z.input; +/** + * The request-signing schemes the platform implements (ADR-0143 D2.3, Q4). + * Closed, and a member enters only together with its signer and its test + * vectors: a scheme listed here without a shipped signer would be a + * declared-but-unenforced property (ADR-0049). Each member names one vendor's + * canonical-request algorithm, implemented in the automation service's signer + * registry, so nothing about the canonicalisation is authored. + */ +export const ConnectorSigningSchemeSchema = z.enum(['aliyun-acs3-hmac-sha256']) + .describe('Request-signing scheme: `aliyun-acs3-hmac-sha256` (Alibaba Cloud OpenAPI V3 signature).'); +export type ConnectorSigningScheme = z.input; + +/** + * The guidance a `signed` instance meets for a key that would carry key + * material or a per-request value: the key is a `credentialRef`, and the + * signature, timestamp and nonce are computed by the signer on every request. + */ +const SIGNED_GUIDANCE: Readonly> = { + secret: inlineSecretGuidance('signed', 'signing key'), + privateKey: inlineSecretGuidance('signed', 'signing key'), + secretKey: inlineSecretGuidance('signed', 'signing key'), + accessKeySecret: inlineSecretGuidance('signed', 'signing key'), + signature: 'Not authorable on `auth: { type: \'signed\' }`. The signature is computed by the platform for every request ' + + 'from the request itself and the key `credentialRef` names, so it never appears in a connector definition. Delete the key.', +}; + +/** + * `signed` with `scheme: 'aliyun-acs3-hmac-sha256'` — Alibaba Cloud's OpenAPI + * V3 signature (ADR-0143 Q4). Every request carries an `Authorization: + * ACS3-HMAC-SHA256 Credential=…,SignedHeaders=…,Signature=…` header, an + * HMAC-SHA256 over the canonical request keyed with the AccessKey secret; the + * signer also sets the `x-acs-date`, `x-acs-signature-nonce` and + * `x-acs-content-sha256` headers it signs. The request names its API with the + * `x-acs-action` and `x-acs-version` headers, which the signer signs as sent. + */ +export const ConnectorInstanceSignedAliyunAcs3AuthSchema = lazySchema(() => strictObject({ + surface: "this `signed` connector auth (`scheme: 'aliyun-acs3-hmac-sha256'`)", + history: 'This shape is strict from birth — the `signed` instance kind had no earlier spelling, so an unknown key here was never accepted.', + guidance: SIGNED_GUIDANCE, + // Alibaba Cloud's own word for the key id. + aliases: { accessKeyId: 'keyId' }, +}, { + type: z.literal('signed'), + scheme: z.literal('aliyun-acs3-hmac-sha256').describe('Alibaba Cloud OpenAPI V3 signature (ACS3-HMAC-SHA256).'), + keyId: z.string().min(1).describe('The AccessKey ID (not a secret); sent in the `Credential` of every signature.'), + credentialRef: z.string().min(1).describe( + 'Secrets-layer reference resolved to the AccessKey secret, the HMAC key every request is signed with. Never an inline secret.', + ), +})); +export type ConnectorInstanceSignedAliyunAcs3Auth = z.input; + +/** + * Per-request signing auth for a provider-bound instance (ADR-0143 D2.3): no + * token exists — the platform signs every outgoing request with the key + * `credentialRef` names, by the vendor's canonical-request algorithm. One + * strict shape per scheme, because each scheme carries its own non-secret + * parameters; {@link ConnectorSigningSchemeSchema} is the closed list. + * + * Accepted on `provider: 'openapi'` and `provider: 'rest'`. An unknown scheme + * is refused at publish naming the value, and the signing key is only ever a + * `credentialRef` (ADR-0097 §3, ADR-0143 D3). + */ +export const ConnectorInstanceSignedAuthSchema = lazySchema(() => z.discriminatedUnion('scheme', [ + ConnectorInstanceSignedAliyunAcs3AuthSchema, +], { + // The refusal names the scheme it got and the ones that ship, rather than + // zod's bare "invalid discriminator": a scheme is a vendor algorithm, and + // an author who wrote another vendor's name must learn it is not signed here. + error: (issue) => { + const input = issue.input; + if (typeof input !== 'object' || input === null) return undefined; + const scheme = (input as { scheme?: unknown }).scheme; + const shipped = ConnectorSigningSchemeSchema.options.map((s) => `'${s}'`).join(', '); + return scheme === undefined + ? `\`auth: { type: 'signed' }\` needs a \`scheme\`: one of ${shipped}.` + : `Unknown signing scheme ${JSON.stringify(scheme)}: this platform signs requests with ${shipped} only. ` + + 'A scheme enters only together with its signer and its test vectors (ADR-0143 D2.3).'; + }, +})); +export type ConnectorInstanceSignedAuth = z.input; + /** * Declarative connector-instance auth: a discriminated union whose secret-bearing * variants carry a `credentialRef` instead of an inline secret (ADR-0097 §3, @@ -389,6 +474,7 @@ export const ConnectorInstanceAuthSchema = lazySchema(() => z.discriminatedUnion ConnectorInstanceBasicAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceAppCredentialAuthSchema, + ConnectorInstanceSignedAuthSchema, ])); export type ConnectorInstanceAuth = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 3a367c502af..65363d6ba76 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 801 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 804 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1170,7 +1170,10 @@ export type Iso_shared_connectorAuth__ConnectorInstanceNoAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2AuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2AuthorizationCodeAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2ClientCredentialsAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2ClientCredentialsAuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceSignedAliyunAcs3AuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceSignedAliyunAcs3AuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceSignedAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceSignedAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorOAuth2GrantSchema = Assert, z.infer< typeof M109.ConnectorOAuth2GrantSchema > >>; +export type Iso_shared_connectorAuth__ConnectorSigningSchemeSchema = Assert, z.infer< typeof M109.ConnectorSigningSchemeSchema > >>; // shared/duration.zod.ts — the closed DURATION vocabulary (#18122), step ① of // ruling A on #18115 and the counterpart of the `shared/epoch` instant. Both are @@ -1735,7 +1738,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 801 isomorphic pins', () => { + it('still declares all 804 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2684,7 +2687,15 @@ describe('ADR-0122 type-alias convention', () => { // …AppCredentialAuthSchema, …ConnectorOAuth2GrantSchema, // …ConnectorAppCredentialVendorSchema) rather than given `…Parsed` // synonyms. +6 added. - expect(pins).toHaveLength(801); + // 801 -> 804 is #26710 (ADR-0143 S1 request signing): the `signed` + // connector instance auth (`shared/connector-auth.zod.ts`) — the union on + // `scheme`, its `aliyun-acs3-hmac-sha256` shape and the `scheme` enum — + // carries no default and no transform, so it is pinned here + // (Iso_shared_connectorAuth__ConnectorInstanceSignedAuthSchema, + // …ConnectorInstanceSignedAliyunAcs3AuthSchema, + // …ConnectorSigningSchemeSchema) rather than given `…Parsed` synonyms. + // +3 added. + expect(pins).toHaveLength(804); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 52e01152990..41022a8de63 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -95,7 +95,7 @@ "docs": [ "content/docs/automation/connectors.mdx" ], - "note": "The declarative connector-instance auth shape (ADR-0097, ADR-0143), reached via EnvironmentArtifactSchema.metadata.connectors[].auth and the assembled package manifest: `credentialRef` references only. `oauth2` is a member too, keyed separately because it is itself a union on `grant` (the walk reports the inner discriminator); see the grant entry below. The guide's 'Grant-bearing kinds' section covers `oauth2` and `app-credential`." + "note": "The declarative connector-instance auth shape (ADR-0097, ADR-0143), reached via EnvironmentArtifactSchema.metadata.connectors[].auth and the assembled package manifest: `credentialRef` references only. `oauth2` and `signed` are members too, keyed separately because each is itself a union (on `grant` and on `scheme`; the walk reports the inner discriminator); see the grant and scheme entries below. The guide's 'Grant-bearing kinds' section covers `oauth2` and `app-credential`, and its 'Request signing' section covers `signed`." }, { "key": "grant:authorization_code|client_credentials", @@ -105,6 +105,14 @@ ], "note": "The `oauth2` member of connector-instance auth, one strict shape per grant (ADR-0143 D2.1): `authorizationUrl` exists only on authorization_code, and client_credentials requires `credentialRef`." }, + { + "key": "scheme:aliyun-acs3-hmac-sha256", + "label": "connector instance signing scheme", + "docs": [ + "content/docs/automation/connectors.mdx" + ], + "note": "The `signed` member of connector-instance auth, one strict shape per scheme (ADR-0143 D2.3, #26710). The list is closed: a scheme joins only with its signer and its vendor test vectors, so this key grows one member per slice that ships one." + }, { "key": "strategy:isolated_db|isolated_schema|shared_schema", "label": "tenant isolation strategy", diff --git a/scripts/adr-anchors/packages__services__service-automation__src__request-signing__index.ts.json b/scripts/adr-anchors/packages__services__service-automation__src__request-signing__index.ts.json new file mode 100644 index 00000000000..ac205f4c79c --- /dev/null +++ b/scripts/adr-anchors/packages__services__service-automation__src__request-signing__index.ts.json @@ -0,0 +1,5 @@ +{ + "file": "packages/services/service-automation/src/request-signing/index.ts", + "adrs": ["ADR-0143", "ADR-0049"], + "invariant": "ADR-0143 D2.3 / D4: a `signed` connector instance's key is bound to its scheme's signer here, on the server, and a provider receives only the bound signer, never the key. The switch is exhaustive over the spec's closed scheme enum, so a scheme cannot be declared without a signer that runs it (ADR-0049: declared is enforced); a scheme enters only with its signer and the vendor's test vectors." +}