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¶
- Bypass risk — Direct
/v1/run/{name}skips edge routes, policies, and route-level auth. - Inconsistent auth story — Operators configure
gateway_auth_modeon triggers, but direct run ignores it. - Incomplete HTTP contract — Triggers declare
http_method(GET, POST, …) but gateway handlers are POST-only for/v1/api/.... - 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.
- 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. - 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 invoke —
POST /invoke/dashboardon 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(+ staticEdgeRoute/ 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/runwith 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_filterswithquery:<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/dashboarddirectly — 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 triggergateway_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 whenGATEWAY_VALIDATE_TRIGGERSis 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_key — auth.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 basic — auth.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:
- External ingress cannot start a workflow without a matching trigger edge route.
- Every public route has an explicit
auth.mode(no implicit default tooauth2without operator awareness). - Declared HTTP methods on triggers are enforced at gateway.
- Policy evaluation runs on all trigger routes; direct run disabled.
- Dashboard and scheduler paths unchanged for authorized internal use.
- Security test suite covers 401/403/404 deny paths without orchestrator side effects.