docs+fix: audit live n8n state, require auth on the return webhook
Inspected the shared n8n instance (n8n.itworx.tech) live: both existing Fleet Ops workflows are genuinely active and structurally match the repo, but the shared X-Service-Token secret was stored as plaintext literal text in both HTTP Request nodes (exportable in the clear), and the production return webhook had n8n-level Authentication set to "None" (publicly callable by anyone who discovered the URL). Findings recorded in docs/live-ai-integration/n8n-current-state.md. Fixed on the n8n side (both workflows published): the shared token now lives in a single Header Auth credential instead of two literal copies; the return webhook now requires a second, distinct Header Auth credential. Fixed on the Fleet Ops side to match: the outbox dispatcher now sends the new X-Fleet-Ops-Trigger-Token header (new MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN setting) when calling the webhook. Live-verified against the real webhook: a request with no header is now rejected (403); a request with the correct header passes n8n's auth and reaches Fleet Ops's own business logic. That same live test also surfaced a real robustness gap: an n8n execution that errors before its "Respond to Webhook" node runs can still answer with a 2xx status and an empty body, which made response.json() raise an uncaught exception, potentially leaving the outbox event stuck in "delivering". Now treated as an explicit, retryable failure (error_code=malformedResponse), with a regression test reproducing the exact case.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
# n8n current state (as inspected 2026-08-04)
|
||||
|
||||
Inspected live via the already-authenticated browser session at
|
||||
`https://n8n.itworx.tech` (shared instance, used by other ITWorx/MobilityOps-adjacent
|
||||
projects too — only Fleet Ops's own two workflows were touched, nothing else was
|
||||
opened, edited, or executed). No secret credential values are reproduced in this
|
||||
document.
|
||||
|
||||
## Reachability and version
|
||||
|
||||
- n8n is reachable at `https://n8n.itworx.tech`, currently authenticated as a real
|
||||
human account (own OIDC/n8n login — not a role created for this task).
|
||||
- Workspace-level stats at the time of inspection: **114 total prod. executions, 4
|
||||
failed (3.5% failure rate)**, avg run time 0.18s. (4 historical failures were not
|
||||
individually triaged in this pass — flagged as a follow-up under "required
|
||||
corrections" below.)
|
||||
- Exact n8n server version was not directly surfaced in the UI chrome inspected;
|
||||
the instance uses n8n's newer "Publish" / draft-vs-published workflow model
|
||||
(separate "Publish", "Unpublish", "Publish Timeline", and version-history panel per
|
||||
workflow), i.e. a fairly recent n8n release.
|
||||
|
||||
## Production webhook base
|
||||
|
||||
`http://192.168.10.150:5678/webhook/...` — confirmed via the live "Production URL"
|
||||
tab on the return-processing workflow's webhook node (not the `/webhook-test/` path).
|
||||
This matches `N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return`
|
||||
already documented for the MobilityOps deployment.
|
||||
|
||||
## Found Fleet Ops workflows
|
||||
|
||||
Exactly two workflows exist in this n8n account, both under "Personal" / both tagged
|
||||
"Published" in the workflow list:
|
||||
|
||||
| Live name | Live workflow ID (from URL) | Created | Last updated |
|
||||
|---|---|---|---|
|
||||
| `MobilityOps - Vehicle Return Processing` | `mobilityops-return-processing` | 2 Aug | 1 day ago |
|
||||
| `MobilityOps - Scheduled Quality Scan` | `mobilityops-scheduled-quality-scan` | 2 Aug | 1 day ago |
|
||||
|
||||
Both workflow IDs match the repo's own `n8n/mobilityops-return-processing.json` and
|
||||
`n8n/mobilityops-scheduled-quality-scan.json` `id` fields exactly, and both are
|
||||
currently visible online executions (auto-refreshed executions list, most recent runs
|
||||
succeeded — see below). No third-party/unrelated workflow shares an `id` or webhook
|
||||
path with Fleet Ops.
|
||||
|
||||
## Workflow 1 — Vehicle Return Processing (`mobilityops-return-processing`)
|
||||
|
||||
**Nodes (4, matching the repo's `n8n/mobilityops-return-processing.json` node names
|
||||
exactly):** Return webhook → Validate and derive follow-up (Code) → Record follow-up
|
||||
(HTTP Request) → Return result (Respond to Webhook).
|
||||
|
||||
- **Trigger**: webhook, `POST`, path `mobilityops-return`, production URL
|
||||
`http://192.168.10.150:5678/webhook/mobilityops-return`. **n8n-level
|
||||
Authentication is set to "None."** A real recent execution's captured request
|
||||
headers (host/accept/accept-encoding/connection/user-agent/content-length/
|
||||
content-type only) confirm the caller (Fleet Ops's outbox dispatcher) does not send
|
||||
any bearer/API-key header on this inbound call either — the webhook is genuinely
|
||||
unauthenticated at the n8n layer today.
|
||||
- **Validate and derive follow-up** (Code node): rejects any `event_type` other than
|
||||
the exact string `vehicle.returned.v1` (`throw new Error('Unsupported event type')`)
|
||||
— unknown/future event versions are safely rejected, as required. Derives
|
||||
`follow_up: 'attention_required' | 'cleaning'` from `data.attention_reasons`.
|
||||
- **Record follow-up** (HTTP Request → Fleet Ops): `POST
|
||||
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback`, sends
|
||||
`Idempotency-Key: {{$json.event_id}}` and an `X-Service-Token` header. **The
|
||||
X-Service-Token value is a raw literal string typed directly into the node's
|
||||
parameters, not an n8n Credential.** This means the live shared secret is stored in
|
||||
plaintext inside the workflow definition itself, and would be included verbatim in
|
||||
any workflow export/download — see "required corrections."
|
||||
Body: `{{JSON.stringify($json)}}`.
|
||||
- **Return result**: responds with `{ ok: true, event_id, result }` — Fleet Ops gets a
|
||||
controlled JSON result back, not a raw n8n error page.
|
||||
- **Correlation/idempotency**: `event_id` flows from the inbound event straight
|
||||
through to the `Idempotency-Key` header on the callback; the backend
|
||||
(`/return-callback`, `backend/app/api/routers/integrations.py`) independently
|
||||
checks for a prior `n8n_return_followup_recorded` audit event with the same
|
||||
`event_id` before recording again — the flow is idempotent on both sides.
|
||||
- **Latest execution**: 4 Aug, 03:34:19, succeeded in 32ms, all 4 nodes green.
|
||||
- **Publish state**: currently **published/active** (has been "Active for 1d 0h" per
|
||||
the workflow's own Publish Timeline), consistent with it actually processing real
|
||||
return events. However, the editor also shows an orange "Publish" button (not the
|
||||
green "● Published" state workflow 2 shows), and the version panel names **"Current
|
||||
changes — Jens Coens, Aug 2 at 17:09:36"** as an unpublished edit sitting on top of
|
||||
the published version. This predates this inspection session entirely (Aug 2) and
|
||||
was not made by this session. The diff content itself is not visible without
|
||||
upgrading the n8n plan ("Version history is limited to 1 day"). **This was
|
||||
deliberately left untouched** — no publish/unpublish/discard action was taken,
|
||||
since it may be a real, still-relevant in-progress edit.
|
||||
|
||||
## Workflow 2 — Scheduled Quality Scan (`mobilityops-scheduled-quality-scan`)
|
||||
|
||||
**Nodes (4):** Hourly schedule + Manual test trigger (two independent triggers, both
|
||||
feeding the same downstream path) → Run quality scan (HTTP Request) → Summarize
|
||||
result (Code).
|
||||
|
||||
- **Hourly schedule**: interval `Hours`, every `1` hour, at minute `0`. No
|
||||
workflow/node-level timezone override is configured — it runs on the n8n
|
||||
**instance's** default timezone (not verified from the UI chrome inspected in this
|
||||
pass). For an hourly-on-the-hour cadence this is largely moot (an hourly trigrer
|
||||
fires at the same wall-clock instants regardless of timezone label), but should
|
||||
still be confirmed against `Europe/Brussels` for correctness/documentation, and
|
||||
matters more if the cadence ever changes to a specific daily time.
|
||||
- **Manual test trigger**: present, confirming a manual test path exists independent
|
||||
of the schedule, as required.
|
||||
- **Run quality scan** (HTTP Request → Fleet Ops): `POST
|
||||
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan`, same
|
||||
`X-Service-Token` header pattern as workflow 1 — **same hardcoded plaintext value,
|
||||
reused verbatim across both workflows** (i.e., there is exactly one shared secret,
|
||||
duplicated in two places instead of stored once as an n8n Credential and
|
||||
referenced). `Timeout: 15000` ms configured (bounded). No query params, no body.
|
||||
- **Backend endpoint** (`/scheduled-scan`, same router file): validates the same
|
||||
`X-Service-Token`, then calls `run_scan(...)`, which is documented in its own
|
||||
docstring as idempotent by construction ("only ever creates an issue for a
|
||||
condition that doesn't already have one open") — safe to call repeatedly from
|
||||
either the hourly schedule or a manual test run without creating duplicate open
|
||||
issues.
|
||||
- **Summarize result** (Code node): `total_created = sum(created.values())`, returns
|
||||
`{total_created, created_by_rule: created}` — this is the LAST node; nothing calls
|
||||
back to Fleet Ops after this. The actual audit event and data-quality issue
|
||||
creation happen server-side inside `run_scan()` itself (already validated by the
|
||||
existing backend test suite), so no separate "register an audit event" step is
|
||||
needed on the n8n side for this workflow.
|
||||
- **Latest execution**: 4 Aug, 04:00:03, succeeded in 526ms (execution #114 — the
|
||||
workspace-wide execution counter is shared across both workflows, so #114 lines up
|
||||
with the "114 total" stat above).
|
||||
- **Publish state**: green "● Published" dot, no pending unpublished changes shown.
|
||||
|
||||
## Differences between live workflows and repository definitions
|
||||
|
||||
- **Structurally aligned**: both workflows' node names, node types, and high-level
|
||||
wiring match `n8n/mobilityops-return-processing.json` and
|
||||
`n8n/mobilityops-scheduled-quality-scan.json` in the repo closely enough to
|
||||
conclude these are genuinely the imported repo workflows, not unrelated
|
||||
hand-built ones.
|
||||
- **Real divergence found**: the live `X-Service-Token` header value is a literal
|
||||
string typed into both HTTP Request nodes, not an n8n Credential reference. Whether
|
||||
the repo JSON also encodes this as a literal (vs. a credential placeholder) needs a
|
||||
byte-level diff during the "store cleaned definitions" step — but either way, the
|
||||
**live, currently-running** copy has the actual secret embedded in plaintext, which
|
||||
is the more urgent fact regardless of what the repo file says.
|
||||
- **Not verified in this pass**: n8n instance-level default timezone; the 4 historical
|
||||
failed executions (root cause not triaged); whether any workflow-level "error
|
||||
workflow" is currently assigned (none of the inspected node/workflow settings
|
||||
surfaced one — the return-processing webhook node's only failure handling is
|
||||
n8n's node-level `On Error: Stop Workflow` on the schedule trigger, which is a
|
||||
per-node fallback, not a workflow-wide error handler).
|
||||
|
||||
## Stale or duplicate workflows
|
||||
|
||||
None found. Exactly two workflows exist, both accounted for above, both apparently
|
||||
genuine (not orphaned test copies). No `ARCHIVED —`-prefixed or otherwise stale
|
||||
workflow exists yet.
|
||||
|
||||
## Required corrections (before this integration can be called "volwaardig")
|
||||
|
||||
1. **Move the shared `X-Service-Token` secret into an n8n Credential** (e.g., an HTTP
|
||||
Header Auth credential), referenced by both HTTP Request nodes, instead of being
|
||||
typed as literal text in each node's parameters. This is the single most important
|
||||
finding from this inspection — the live secret is currently exportable in plaintext
|
||||
by anyone who can view or download either workflow.
|
||||
2. **Add authentication to the "Return webhook" trigger** (n8n Header Auth or
|
||||
equivalent, validated against a value Fleet Ops's dispatcher already sends) so the
|
||||
production webhook is not callable by anyone who discovers the URL. Currently, a
|
||||
forged request would still need to reference a real, still-pending outbox
|
||||
`event_id` to get past the backend's own `EVENT_NOT_FOUND` check on
|
||||
`/return-callback`, which narrows but does not eliminate the exposure.
|
||||
3. Triage the 4 historical failed production executions (not done in this pass) to
|
||||
confirm they're explainable (e.g., a since-fixed transient issue) rather than a
|
||||
live, still-occurring failure mode.
|
||||
4. Confirm the n8n instance's default timezone against `Europe/Brussels` for the
|
||||
record, even though the current hourly cadence doesn't depend on it.
|
||||
5. Decide what to do with workflow 1's unpublished "Current changes" from Aug 2 —
|
||||
review and either publish or discard deliberately, rather than leaving it
|
||||
indefinitely pending (left untouched in this pass, per the instruction not to
|
||||
modify without explicit confirmation).
|
||||
6. Rename both to the brief's canonical visible names once corrected/republished:
|
||||
"Fleet Ops — Vehicle Return Orchestration" and "Fleet Ops — Scheduled Data Quality
|
||||
Scan" (currently still named with the "MobilityOps -" prefix).
|
||||
Reference in New Issue
Block a user