Compare commits

...

2 Commits

Author SHA1 Message Date
Stéphane Tailland 0847e11713 Gate the n8n trigger endpoints on Keycloak
Every webhook on this instance was answering unauthenticated requests, the
stock watchlist included — it returned references and stock levels and let
anyone clear entries. The A2A facade in front of these workflows enforces a
bearer, but the workflows stayed reachable directly, facade bypassed.

Keycloak's userinfo is the authenticator: 200 for a valid bearer in the realm,
401 otherwise, which is the auth-request contract exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 07:56:13 +02:00
Stéphane Tailland 06d0ab5e2d Wake Approvisionnement on a stock event instead of a clock
The reactive half of the agent. It consumes product.stock_changed from the
catalog topic and reports only a crossing of the threshold — every further
order line on an already-low reference would otherwise raise the same alarm,
and an agent that repeats itself is one a human learns to ignore. A stock-out
is treated as its own fact even when the reference was already low.

The detection lands on a watchlist readable over a webhook, because a proposal
that reached only an execution log would be lost between two nightly runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 07:46:24 +02:00
5 changed files with 220 additions and 0 deletions
+23
View File
@@ -176,3 +176,26 @@ endpoints and the known blockers.
Deliverables in English. Export workflows as JSON and commit them; do not rely Deliverables in English. Export workflows as JSON and commit them; do not rely
on the n8n instance as the source of truth. on the n8n instance as the source of truth.
## The Kafka credential is created by hand
`kafka-classic-models` in n8n holds the SCRAM password of the
`classic-models-agent-approvisionnement` KafkaUser and the Strimzi cluster CA.
It is not in this repo and must not be: n8n encrypts stored credentials with
`N8N_ENCRYPTION_KEY`, and the committed workflow JSON references it by id only.
To recreate it after a rebuild:
```
kubectl -n kafka get secret classic-models-agent-approvisionnement \
-o jsonpath='{.data.password}' | base64 -d
kubectl -n kafka get secret kafka-cluster-ca-cert \
-o jsonpath='{.data.ca\.crt}' | base64 -d
```
then POST to `/rest/credentials` with type `kafka`, `ssl: true`, the CA in the
`ca` field, `saslMechanism: scram-sha-512`, brokers
`kafka-kafka-bootstrap.kafka.svc.cluster.local:9093`. The workflow's
`credentials.kafka.id` then needs the new id.
The KafkaUser itself lives in `infra-kafka/kafka-users.yaml`.
View File
+10
View File
@@ -27,6 +27,16 @@ spec:
TIMEZONE: Europe/Paris TIMEZONE: Europe/Paris
INGRESS_CLASS: nginx INGRESS_CLASS: nginx
INGRESS_BODY_SIZE: "50m" INGRESS_BODY_SIZE: "50m"
# Every trigger endpoint is gated by this. Keycloak's userinfo answers 200
# for a valid bearer in the realm and 401 otherwise, which is exactly the
# auth-request contract, so nothing had to be built for it. In-cluster,
# over http: the subrequest never leaves the cluster and needs no CA.
#
# It authenticates, it does not authorise: any token this realm issued
# gets through, including an agent's own service account. The finer grain
# belongs to the workflow, which exchanges the caller's token and is
# refused by the domain if the caller may not read it.
N8N_WEBHOOK_AUTH_URL: http://keycloak.keycloak.svc.cluster.local:8080/realms/classic-models/protocol/openid-connect/userinfo
TLS_ISSUER_NAME: sttlab-local-ca TLS_ISSUER_NAME: sttlab-local-ca
TLS_ISSUER_KIND: ClusterIssuer TLS_ISSUER_KIND: ClusterIssuer
VAULT_SERVER: https://vault.sttlab.eu VAULT_SERVER: https://vault.sttlab.eu
+72
View File
@@ -147,3 +147,75 @@ looks exactly like one computed from all of it, so the difference has to be in
the output. A catalog that outgrows one page needs a paging loop **and** the the output. A catalog that outgrows one page needs a paging loop **and** the
read-volume cap this repo already lists as mandatory for autonomous agents. read-volume cap this repo already lists as mandatory for autonomous agents.
## approvisionnement-stock-watch
The reactive half, and the one the design had been waiting on. It is woken by
`product.stock_changed` on `classic-models.catalog` rather than by a clock, so a
reference that runs low at 14:32 is noticed at 14:32 instead of being reported
at 06:00 the next morning.
Verified end to end against the live catalog:
```
S12_1099 68 → 38 detected: "fell from 68 to 38, below the threshold of 50"
38 → 30 not detected — already below, figures refreshed instead
30 → 0 detected: "is out of stock. Nothing can be promised."
0 → 68 not detected, entry refreshed (a correction, not demand)
POST {"clear":["S12_1099"]} → watching: 0
```
### Only a crossing is news
`before >= threshold && after < threshold`. Without that test, every further
order line on an already-low reference raises the same alarm again, and an agent
that repeats itself is one a human learns to ignore.
A stock-out is treated as its own fact even when the reference was already below
the threshold: *low* and *nothing left to promise* are different problems.
A movement that changes nothing newsworthy still **refreshes** an entry already
being watched. Otherwise the watchlist would show the figure from the moment of
detection, which is the number least likely to still be true.
`reason` carries `order:<n>` for demand and `manual-adjustment` for someone
correcting the books. Both can justify a purchase order; only demand says
anything about the future, so the distinction is recorded rather than filtered
on.
### The detection has somewhere to go
`$getWorkflowStaticData('global')`, keyed by reference, readable over
`GET /webhook/stock-watchlist` and cleared with
`POST {"clear": ["S24_2000"]}`. The purchase *proposal* still has nowhere to be
written — the domain has no purchase order and no supplier entity — but a
detection that only reached an execution log would be lost between two nightly
runs, which would make the reactive half pointless.
The obvious next consumer is `check-availability`: "can we promise X?" is a
better answer when it knows X crossed the threshold at 14:32.
### Kafka details worth keeping
- **The offset moves only on success** (`resolveOffset: onCompletion`,
`allowedStatuses: ["success"]`). A failed execution replays its event rather
than losing it — for an autonomous agent a detection dropped in silence is
worse than one made twice.
- **`fromBeginning: false`.** Otherwise every restart replays the whole
retention window and re-detects crossings dealt with days ago.
- Most messages on the topic are not `stock_changed`, and the workflow says so
explicitly and succeeds. A workflow that threw on an uninteresting event would
replay it forever.
- The consumer has its **own** SCRAM account,
`classic-models-agent-approvisionnement`: Read on that one topic and that one
consumer-group prefix. It cannot read sales or payments, and it cannot write —
so a compromised workflow can raise no event another agent would act on.
### Two traps
- **n8n's Kafka credential has a `ca` field.** The cluster CA goes there, so
there was no need to touch `NODE_EXTRA_CA_CERTS` or to disable verification.
Not obvious from the node's UI, which shows it only once `ssl` is on.
- **A webhook declaring several methods has one output per method**, in the
order declared. Wiring only the first silently drops every POST: the execution
reports **success** and n8n answers `No item to return was found`.
@@ -0,0 +1,115 @@
{
"name": "Approvisionnement \u2014 stock watch",
"nodes": [
{
"parameters": {
"topic": "classic-models.catalog",
"groupId": "approvisionnement-stock-watch",
"resolveOffset": "onCompletion",
"allowedStatuses": [
"success"
],
"options": {
"jsonParseMessage": true,
"onlyMessage": true,
"fromBeginning": false
}
},
"type": "n8n-nodes-base.kafkaTrigger",
"typeVersion": 2,
"position": [
0,
0
],
"id": "kafka-trigger-stock-watch",
"name": "Catalog events",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// A stock movement, judged the moment it happens.\n//\n// This is the reactive half of Approvisionnement. The 06:00 run proposes\n// purchase orders on a buying cycle, which is genuinely periodic; noticing that\n// a reference is about to run out is not, and a nightly sweep would report at\n// 06:00 what was true at 14:32 the previous afternoon.\n//\n// What it reacts to is a fact the API published, not a row it went looking for.\nconst THRESHOLD = 50;\nconst COVER = 3; // a proposal covers three times the threshold\nconst STOCK_CHANGED = 'com.sttlab.classicmodels.product.stock_changed.v1';\n\nconst event = $json ?? {};\n\n// The topic carries every catalog event, so most messages are not ours. Saying\n// so explicitly keeps the offset moving: a workflow that threw on an\n// uninteresting event would replay it forever.\nif (event.type !== STOCK_CHANGED) {\n return [{ json: { considered: false, type: event.type ?? 'unreadable' } }];\n}\n\nconst data = event.data ?? {};\nconst before = Number(data.before?.quantityinstock);\nconst after = Number(data.after?.quantityinstock);\nif (!Number.isFinite(before) || !Number.isFinite(after)) {\n return [{ json: {\n considered: false,\n escalate: true,\n reason: `${event.subject}: the movement carried no readable quantities.`,\n }}];\n}\n\n// Only a *crossing* is news. Without this, every further order line on an\n// already-low reference raises the same alarm again, and an agent that repeats\n// itself is one a human learns to ignore.\nconst crossed = before >= THRESHOLD && after < THRESHOLD;\n// A stock-out is a different fact from a low stock, even when the reference was\n// already below the threshold \u2014 nothing can be promised at all.\nconst emptied = after === 0 && before > 0;\n\n// `order:` means demand; anything else is someone correcting the books. Both\n// justify a purchase order, but only demand is a signal about the future.\nconst fromDemand = String(data.reason ?? '').startsWith('order:');\n\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\nif (!crossed && !emptied) {\n // Not news, but if this reference is already being watched its figures have\n // just moved. Refresh them rather than leaving a human reading the number it\n // held at the moment of detection, which is the number least likely to still\n // be true.\n const watched = store.watchlist[event.subject];\n if (watched) {\n watched.on_hand = after;\n watched.propose_order = THRESHOLD * COVER - after;\n watched.last_movement_at = event.time ?? new Date().toISOString();\n }\n return [{ json: {\n considered: true,\n detected: false,\n watching: Boolean(watched),\n productcode: event.subject,\n on_hand: after,\n }}];\n}\n\nconst state = { detected: true, escalate: true,\n productcode: event.subject,\n on_hand: after,\n was: before,\n threshold: THRESHOLD,\n stock_out: after === 0,\n driver: fromDemand ? 'demand' : 'correction',\n movement: data.reason ?? null,\n propose_order: THRESHOLD * COVER - after,\n observed_at: event.time ?? new Date().toISOString(),\n // Kept so this detection can be joined to the request that caused it. It is\n // the only link back \u2014 the event deliberately names no actor.\n traceparent: event.traceparent ?? null,\n};\n\nstate.reason = state.stock_out\n ? `${state.productcode} is out of stock (was ${before}). Nothing can be promised.`\n : `${state.productcode} fell from ${before} to ${after}, below the threshold of ${THRESHOLD}.`;\n\n// The watchlist is the destination, and it is a real one: the proposal still\n// has nowhere to be written \u2014 the domain has no purchase order and no supplier\n// entity \u2014 so a detection that only logged would be lost between two nightly\n// runs. Here it survives, keyed by reference so a reference cannot appear twice.\nstore.watchlist[state.productcode] = state;\n\nreturn [{ json: state }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"id": "detect-crossing",
"name": "Judge the movement"
},
{
"parameters": {
"httpMethod": [
"GET",
"POST"
],
"path": "stock-watchlist",
"responseMode": "lastNode",
"options": {},
"multipleMethods": true
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
200
],
"id": "watchlist-read",
"name": "Watchlist",
"webhookId": "stock-watchlist-get"
},
{
"parameters": {
"jsCode": "// What has been detected and not yet dealt with.\n//\n// A detection that goes only into an execution log is one nobody reads. This\n// makes the watchlist readable \u2014 by the periodic run, which can start from what\n// was already noticed rather than rediscovering it, and by a human.\n//\n// GET /webhook/stock-watchlist \u2192 the current list\n// POST /webhook/stock-watchlist {\"clear\": [\"S24_2000\"]} \u2192 drop entries\nconst store = $getWorkflowStaticData('global');\nstore.watchlist = store.watchlist ?? {};\n\n// GET carries no body; the node still runs, and clear is then empty.\nconst body = $json.body ?? {};\nconst clear = Array.isArray(body.clear) ? body.clear : [];\nfor (const code of clear) delete store.watchlist[code];\n\nconst entries = Object.values(store.watchlist);\nreturn [{ json: {\n watching: entries.length,\n cleared: clear,\n // Worst first: an out-of-stock reference is not the same problem as one that\n // merely crossed the threshold.\n entries: entries.sort((a, b) => a.on_hand - b.on_hand),\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
200
],
"id": "watchlist-render",
"name": "Render the watchlist"
}
],
"connections": {
"Catalog events": {
"main": [
[
{
"node": "Judge the movement",
"type": "main",
"index": 0
}
]
]
},
"Watchlist": {
"main": [
[
{
"node": "Render the watchlist",
"type": "main",
"index": 0
}
],
[
{
"node": "Render the watchlist",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}