Skip to content

ADR 0009 — Trigger-only gateway exposure and edge security model

Status: Accepted (Phase 1 implemented 2026-06-29)
Date: 2026-06-29
Related: ADR 0005, Gateway API, Gateway hardening, Gateway flow policies, internal/edge-control-layer-feature.md, internal/edge-control-layer-implementation-backlog.md


Context

MeshFlows workflows must only be started from controlled surfaces:

Surface Network Purpose
Dashboard Internal (cluster / VPN) Operator runs, RBAC, debugging
HTTP trigger Public (via gateway) Partner / product API contracts
Schedule trigger Internal (scheduler → orchestrator) Cron / interval automation
AMQP trigger Internal (queue subscriber) Event-driven automation

Today the gateway still exposes direct workflow invocation (POST /v1/run/{workflow_name}) and utility APIs (/v1/transform, /v1/traces/*) that are not tied to a declared trigger contract. That creates a security gap: any caller who knows or guesses a workflow name can bypass trigger URL design, HTTP method policy, gateway policies (transform, required headers, rate limits), and per-route authentication.

Product direction (see ADR 0005) is that triggers are external documents (flows/triggers/*.yaml) defining the start contract. The gateway is the edge control layer: it decides whether an endpoint is public or private, which authentication applies, which HTTP methods and query parameters are valid, and which policies run before orchestration.

This ADR treats misconfigured public exposure as a critical security issue, not a convenience feature.


Problem statement

  1. Bypass risk — Direct /v1/run/{name} skips edge routes, policies, and route-level auth.
  2. Inconsistent auth story — Operators configure gateway_auth_mode on triggers, but direct run ignores it.
  3. Incomplete HTTP contract — Triggers declare http_method (GET, POST, …) but gateway handlers are POST-only for /v1/api/....
  4. Missing auth modes — Operators need OAuth2 (scoped JWT), Bearer token / API key, function key (header or query), HTTP Basic, and explicit none (truly public). Basic and dedicated function-key modes are not fully implemented.
  5. Transform at edge — JSON↔XML and contract normalization belong in gateway policies (type: transform), not in workflow steps, so public contracts can evolve without changing workflow logic.
  6. Defense in depth — Gateway is the policy decision point; orchestrator keeps invocation checks (tokens, trigger filters, throttle) as a second layer.

Decision

1. Workflow start surfaces (normative)

A workflow run MUST originate from exactly one of:

  • Dashboard invokePOST /invoke/dashboard on orchestrator (via dashboard /api/run/{name}), RBAC + optional service token. Never exposed on the public gateway.
  • Declared HTTP trigger — Gateway edge route derived from trigger invocation.gateway_public_path (+ static EdgeRoute / webhook config). Only public HTTP entry for execution.
  • Declared schedule trigger — Scheduler → POST /invoke/scheduled. Internal only.
  • Declared AMQP trigger — RabbitMQ subscriber. Internal only.

MUST NOT (for external / untrusted callers):

  • Start a workflow by internal workflow name on a public URL (/v1/run/{workflow_name}, /v1/run with body workflow field).
  • Start a workflow without a matching trigger binding when GATEWAY_VALIDATE_TRIGGERS=true.

Sub-workflow steps (workflow step type) are in-run composition, not a new start surface.

2. Gateway exposure model

Public gateway (untrusted network):

Allowed Examples
Trigger edge routes POST /v1/api/orders/create (path from trigger)
Webhook edge routes POST /v1/webhooks/partner-events
Operational GET /healthz, GET /readyz
Optional operator status GET /v1/status (recommend internal network or auth)

Not allowed on public gateway (production default):

Disallowed Replacement
POST /v1/run/{workflow_name} Trigger gateway_public_path
POST /v1/run (workflow in body) Same
Ungated /v1/transform Transform via type: transform policy on trigger route
Ungated /v1/traces/* Dashboard proxy or internal-only ingress

Production clusters MUST set GATEWAY_DISABLE_DIRECT_RUN=true. Development may opt out with explicit env override.

3. Trigger document owns the HTTP contract

Each HTTP trigger (flows/triggers/*.yaml) defines:

name: orders-http
target_workflow: order-create-internal   # internal name; may differ from public path
invocation:
  type: http
  http_method: [POST, GET]               # allowed methods for this endpoint
  gateway_public_path: /v1/api/orders/create
  gateway_auth_mode: oauth2              # see auth catalog below
  gateway_policy_bindings:               # optional subset of policies
    - require-tenant-headers
    - json-to-xml-inbound
  trigger_filters:                       # evaluated at orchestrator (defense in depth)
    - property: query:api-version
      equals: "v1"

Gateway merges triggers per target_workflow (union of methods; first-wins for path/auth unless product later adds explicit merge rules).

4. Gateway policies own edge behaviour

Policies (flows/policies/*.yaml) run on edge routes before orchestrator invocation:

Policy type Role
required_headers Enforce or log missing headers
oauth_inbound External IdP token introspection
rate_limit_product Product / tenant quotas via identity
transform Inbound JSON→XML, outbound XML→JSON, error envelopes

Route-level request / response header transforms (add_headers, set_properties) complement policies.

Query parameter rules:

  • Gateway (edge): EdgeRoute.constraints.required_query — reject before orchestrator (planned).
  • Orchestrator: trigger_filters with query:<name> — defense in depth after gateway allows the request.

5. Per-route authentication catalog

Every edge route MUST declare auth.mode. This is the operator control for public vs private.

Mode Use case Gateway behaviour
none Truly public webhook or health-style API No credential check
token Private but scope-agnostic Valid Bearer (JWT or mk_ API key); no scope list required
oauth2 Private product API Valid Bearer + all required_scopes present
api_key Machine-to-machine with MeshFlows keys Valid Bearer mk_* key only (reject JWT)
function_key Azure Functions–style partner keys Valid key in configured header or query param; validated against secret ref (planned)
basic Legacy HTTP Basic partners Authorization: Basic … validated against secret ref or identity (planned)

Retrospective (implemented today): none, token, oauth2; API keys work as Bearer mk_* under token / oauth2. Explicit api_key mode enforcement and basic / function_key are not complete.

Trigger field gateway_auth_mode maps to EdgeRoute.auth.mode. Scopes for oauth2 come from route config or identity product bindings (dashboard / EdgeRoute.auth.required_scopes).

6. Security layers (defense in depth)

Client
  → Gateway: TLS, CORS, security headers, rate limit
  → Gateway: route match (method + path)
  → Gateway: auth.mode + policies
  → Gateway: payload constraints (size, content-type)
  → Gateway: transform inbound (policy)
  → Orchestrator: invocation token / OAuth scopes
  → Orchestrator: trigger_filters, throttle, idempotency
  → Workflow execution
  → Gateway: transform outbound (policy)
  → Client

Deny paths MUST NOT call orchestrator (gateway) or start run_workflow() (orchestrator filters).

7. Dashboard and internal paths

Dashboard MUST remain on internal ingress:

  • Authenticated users with meshflows:workflow:run:{name} scope (or admin).
  • Calls orchestrator /invoke/dashboard directly — not via gateway public routes.
  • No requirement for HTTP trigger binding on dashboard runs (operator/testing).

Scheduler and AMQP MUST use dedicated orchestrator entrypoints with service tokens; never gateway public URLs.


Consequences

  • Breaking change (opt-in then default): Clients using /v1/run/{name} must migrate to trigger gateway_public_path.
  • Operational: Every public workflow needs a trigger YAML with path + auth + policy bindings.
  • Documentation: Gateway API reference and hardening docs must list auth catalog and disallow direct run in production.
  • Dashboard: Trigger designer must expose gateway_public_path, gateway_auth_mode, methods, policy bindings.
  • Testing: Security regression tests for “no trigger route → 404”, “wrong auth → 401/403”, “direct run disabled → 404”.
  • Orchestrator: Keeps _require_http / trigger validation when GATEWAY_VALIDATE_TRIGGERS is mirrored on orchestrator side.

Threat model (summary)

Threat Mitigation
Workflow name enumeration via /v1/run/* Disable direct run; 404 on unknown edge routes only
Auth bypass on public URL Per-route auth.mode; policies before orchestrator
Missing tenant / partner isolation required_headers, oauth2 scopes, rate limits keyed by subject / header
Payload abuse max_payload_bytes, content-type constraints
Credential leakage (function keys in URL) Prefer header-based function keys; document query key risk
Dual-stack confusion during migration Env flags + deprecation headers on legacy routes (dev only)

Implementation plan

Phased delivery. Each phase has a security gate before production enablement.

Phase 0 — Document and flag (Sprint 0, ~1 week)

ID Task Owner DoD
P0-1 Accept this ADR; link from gateway wiki Platform ADR in index + mkdocs
P0-2 Document production env: GATEWAY_DISABLE_DIRECT_RUN=true, GATEWAY_VALIDATE_TRIGGERS=true Ops Runbook section
P0-3 Audit existing deployments for /v1/run usage Ops Inventory list
P0-4 Add CI test: direct run returns 404 when flag set Engine test_gateway.py extended

Phase 1 — Trigger-only HTTP entry (Sprint 1–2)

ID Task DoD
P1-1 Default GATEWAY_DISABLE_DIRECT_RUN=true in prod overlays (k8s/overlays/prod, acc) Manifests merged
P1-2 Remove or internal-network restrict /v1/run and /v1/run/{name} in ingress External ingress 404
P1-3 Ensure all demo/prod workflows have trigger with gateway_public_path flows/triggers/ complete
P1-4 Gateway: multi-method dispatch — register routes per http_method, not only @app.post GET/PUT/PATCH/DELETE work when declared
P1-5 Gateway: OPTIONS for CORS preflight on edge routes Preflight 204
P1-6 Orchestrator: reject HTTP /run/{name} when workflow has no allows_http() 403 with clear message
P1-7 Integration test: only trigger path starts workflow externally E2E green

Phase 2 — Edge constraints and query contract (Sprint 2–3)

ID Task DoD
P2-1 Add EdgeRoute.constraints.required_query: list[str] Schema + validation
P2-2 Enforce required query keys in _enforce_edge_constraints 400 before orchestrator
P2-3 Forward query params to orchestrator request_query_params (verify parity) Trigger filters on query work
P2-4 Dashboard trigger editor: query requirements + filters UX Fields saved to trigger YAML
P2-5 Document trigger_filters vs gateway required_query Wiki updated

Phase 3 — Auth modes completion (Sprint 3–4) — Security critical

ID Task DoD
P3-1 Implement auth.mode: api_key in _enforce_edge_auth — reject non-mk_ tokens Unit tests
P3-2 Design function_keyauth.function_key: { header \| query, name, secret_ref } Schema in EdgeRouteAuth
P3-3 Implement function key validation (constant-time compare, secret resolver) 401 on mismatch
P3-4 Design basicauth.basic: { secret_ref \| identity_realm } ADR appendix or follow-up
P3-5 Implement HTTP Basic validation in gateway 401 + WWW-Authenticate: Basic
P3-6 Map gateway_auth_mode enum in trigger schema to full catalog Validation errors on unknown mode
P3-7 Dashboard: auth mode picker with help text (public vs private) UI
P3-8 Security review: auth bypass, timing attacks, secret logging Sign-off checklist

Phase 4 — Transform policies end-to-end (Sprint 4–5)

ID Task DoD
P4-1 Apply transform.inbound in gateway before orchestrator (JSON body → XML) transform_testing_demo E2E
P4-2 Apply transform.outbound on orchestrator response Client receives JSON if configured
P4-3 Apply transform.on_error for 4xx/5xx from orchestrator Standard error envelope
P4-4 Support Content-Type: application/json on edge routes (not only XmlOnlyBody) Content negotiation
P4-5 Restrict /v1/transform to internal network or remove from public ingress No public transform bypass

Phase 5 — Observability, audit, hardening (Sprint 5–6)

ID Task DoD
P5-1 Structured edge.decision logs (allow/deny, route_id, auth_mode, reason) Queryable in Loki/etc.
P5-2 Metrics: gateway_edge_requests_total{route,decision}, rate limit hits Prometheus
P5-3 Internal-only /v1/traces or dashboard-only proxy Ingress policy
P5-4 Emergency bypass: document GATEWAY_DISABLE_DIRECT_RUN=false rollback (dev/staging only) Runbook
P5-5 Pen-test scenarios: direct run, wrong method, missing scope, key in query leak Test report

Phase 6 — Migration and deprecation (ongoing)

ID Task DoD
P6-1 Migration guide: /v1/run/foo/v1/api/... from trigger Wiki how-to
P6-2 Optional Deprecation / Sunset headers on legacy routes (pre-flag environments) Headers present
P6-3 Remove legacy /v1/run handlers in major version bump Changelog

Acceptance criteria (release gate)

Before marking this ADR Accepted for production:

  1. External ingress cannot start a workflow without a matching trigger edge route.
  2. Every public route has an explicit auth.mode (no implicit default to oauth2 without operator awareness).
  3. Declared HTTP methods on triggers are enforced at gateway.
  4. Policy evaluation runs on all trigger routes; direct run disabled.
  5. Dashboard and scheduler paths unchanged for authorized internal use.
  6. Security test suite covers 401/403/404 deny paths without orchestrator side effects.

See also