Skip to content

Commit de28d38

Browse files
os-steveclaudeclaude[bot]
authored
docs(webhooks): custom headers live in the encrypted headers_secret, not definition_json (#9928) (#10067)
The `sys_webhook` section described the pre-#7986 world: it named `definition_json` as the carrier of the custom HTTP headers and had no `headers_secret` row at all, so the field table — which documents its sibling `signing_secret` correctly — read as "this column does not exist". Following it puts an `Authorization: Bearer …` into an ordinary `textarea` that `GET /api/v1/data/sys_webhook` returns in full. Measured from source, not restated from the card: - `headers_secret` is declared `Field.secret()` in `packages/plugins/plugin-webhooks/src/sys-webhook.object.ts`; - the engine encrypts it on write into `sys_secret`, keeps an opaque ref, and refuses the write outright when no CryptoProvider is registered (`encryptSecretFields`, `packages/objectql/src/engine.ts`); - every generic read is masked to `SECRET_MASK` — eight U+2022 bullets, `packages/spec/src/data/secret-mask.ts` — unset reads stay `null`, and an echoed mask is dropped as "unchanged"; - the plaintext is reachable only in-process via `engine.resolveSecretField()`, which the auto-enqueuer calls on each cache refresh (`webhook-headers.ts` / `auto-enqueuer.ts` `attachHeaders`). The page now states what `definition_json` carries today, adds the `headers_secret` row mirroring `signing_secret`, answers what a read returns and who can still reach the plaintext, and extends the v17 callout to cover the headers move — including the legacy blob read that still serves un-migrated rows, marked as a compatibility path and not a place to author. Docs prose only; no code, schema or plugin changes. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent b5f6b26 commit de28d38

1 file changed

Lines changed: 57 additions & 16 deletions

File tree

content/docs/automation/webhooks.mdx

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,14 @@ CRUD, permissions, audit, and Studio UI without bespoke code.
8484
### 3.1 `sys_webhook`
8585

8686
The subscription record. One row per "I want webhook X to fire for object Y".
87-
The transport configuration (headers, timeout, method) is carried in
88-
`definition_json`, a serialised `Webhook` JSON (canonical schema:
89-
`WebhookSchema`, exported from `@objectstack/spec/automation`). The signing
90-
secret is the one authored value that does **not** live in that blob — it has
91-
its own encrypted column, see below.
87+
The authored envelope — the per-attempt `timeoutMs` and the rest of the
88+
`Webhook` config — is carried in `definition_json`, a serialised `Webhook` JSON
89+
(canonical schema: `WebhookSchema`, exported from
90+
`@objectstack/spec/automation`). Two authored values do **not** live in that
91+
blob: the signing secret and the custom HTTP headers each have their own
92+
encrypted column (`signing_secret`, `headers_secret`). Both are the ordinary
93+
place a credential goes, and `definition_json` is an ordinary `textarea`
94+
returned in full by `GET /api/v1/data/sys_webhook`.
9295

9396
| Field | Type | Notes |
9497
|-------------------|-----------|------------------------------------------------------------------------------------|
@@ -101,24 +104,62 @@ its own encrypted column, see below.
101104
| `method` | select | HTTP method — one of `GET` / `POST` / `PUT` / `PATCH` / `DELETE`. Default `POST`. |
102105
| `description` | textarea | Free-text description. |
103106
| `active` | boolean | Inactive webhooks are skipped by the dispatcher. Default `true`. |
104-
| `definition_json` | textarea | Serialised `Webhook` JSON (`WebhookSchema` from `@objectstack/spec/automation`) — carries the transport config: custom `headers` and `timeoutMs`. **Not** the signing secret. |
107+
| `definition_json` | textarea | Serialised `Webhook` JSON (`WebhookSchema` from `@objectstack/spec/automation`) — the per-attempt `timeoutMs` and the rest of the authored envelope. **Not** the signing secret and **not** the custom headers: each has its own encrypted column below. |
108+
| `headers_secret` | secret | Custom HTTP headers sent with every delivery, authored as a JSON object of string values (`{"Authorization": "Bearer …"}`) — a `Field.secret`, the same channel as `signing_secret`. Encrypted on write into `sys_secret`; the row keeps only an opaque ref and every read path returns a mask, so the headers are not recoverable over the data API. Leave the mask untouched when editing to keep the current value. A plaintext that is not a flat map of string values is refused at the write door (`VALIDATION_ERROR` / `400`), not at the next delivery. |
105109
| `signing_secret` | secret | The HMAC-SHA256 key (`Field.secret`). Encrypted on write into `sys_secret`; the row keeps only an opaque ref and every read path returns a mask, so the key is not recoverable over the data API. Leave the mask untouched when editing to keep the current value. |
106110
| `created_at` | datetime | Standard audit columns. |
107111
| `updated_at` | datetime | |
108112

109113
Matching at runtime is purely `object_name` + the multi-select `triggers`
110-
list; the headers and per-attempt timeout are parsed out of `definition_json`
111-
when an event is enqueued, and the signing key is dereferenced server-side from
112-
`signing_secret`. There is no per-row org/tenant column, no `events[]` glob
113-
field, no stored `retry_policy`, and no `secret_hint` — see §6 for the actual
114-
signing model and §11 for the (single) retry budget.
114+
list; the per-attempt timeout is parsed out of `definition_json` when an event
115+
is enqueued, and **both** credentials are dereferenced server-side when the
116+
enqueuer refreshes its subscription cache — the signing key from
117+
`signing_secret`, the custom header map from `headers_secret`. There is no
118+
per-row org/tenant column, no `events[]` glob field, no stored `retry_policy`,
119+
and no `secret_hint` — see §6 for the actual signing model and §11 for the
120+
(single) retry budget.
121+
122+
**What a read of the two encrypted columns returns, and to whom.** Both are
123+
masked on *every* generic read — `find` / `findOne` / `$expand`, and therefore
124+
the REST data API and the Studio alike — unconditionally, after hooks, with no
125+
persona carve-out and no privileged-caller exception on that path. A set value
126+
comes back as the mask (`SECRET_MASK`, eight `` characters, ADR-0100); an
127+
unset one comes back as `null`, so a form can render "configured" vs "not
128+
configured" without seeing either value. Writing that exact mask string back
129+
means "unchanged" and is dropped, which is what makes editing the rest of the
130+
record around an untouched mask safe. The plaintext is reachable **only
131+
in-process**, through the engine's privileged `resolveSecretField()`
132+
dereference: the auto-enqueuer calls it on each cache refresh, keeps the values
133+
in memory, signs the body with one and attaches the other to the outbound
134+
request. No query string reaches that method, so nothing on the data API hands
135+
either value back, for any persona.
136+
137+
Both columns are equally fail-closed on the way in and on the way out: with no
138+
`CryptoProvider` registered the engine **refuses** the write rather than storing
139+
cleartext, and a stored header map that cannot be resolved back into a flat
140+
string map parks the subscription — reported at `error` — rather than
141+
delivering it without the headers it was authored with. The per-delivery *copy*
142+
of those headers is a different mechanism with its own rule: it is snapshotted
143+
onto `sys_http_delivery.headers_json`, which is `internal` rather than
144+
encrypted — see §3.2.
115145

116146
<Callout type="info">
117-
Until v17 the authored `secret` was serialised into `definition_json` along with
118-
the rest of the envelope, which made it readable through an ordinary
119-
`GET /api/v1/data/sys_webhook`. It now goes to `signing_secret`; existing rows
120-
are migrated on boot. Authoring does not change — `defineWebhook({ secret })` is
121-
written exactly as before.
147+
Until v17 the authored `secret` **and** the authored `headers` were serialised
148+
into `definition_json` along with the rest of the envelope, which made both
149+
readable through an ordinary `GET /api/v1/data/sys_webhook`. They now go to
150+
`signing_secret` and `headers_secret`; existing rows are migrated on boot by one
151+
idempotent sweep that moves both passengers and strips them from the blob in a
152+
single update. Authoring does not change — `defineWebhook({ secret, headers })`
153+
is written exactly as before, and the boot materializer routes each value to its
154+
own column.
155+
156+
A row the sweep has not converted — for example a pre-v17 row on a runtime with
157+
no `CryptoProvider` wired, where the encrypted write is refused and the row is
158+
deliberately left intact — is still delivered from the blob, with a `warn`
159+
naming those headers as cleartext. That fallback exists for rows written before
160+
the move; it is **not** a supported place to author. Never type a token into
161+
`definition_json`: it is returned in full by the generic data API, and nothing
162+
ages it out.
122163
</Callout>
123164

124165
### 3.2 `sys_http_delivery`

0 commit comments

Comments
 (0)