From a11da865915eb2ea6be1278c52b0dda6f99db480 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 13:48:18 +0200 Subject: [PATCH] ADR 0108: a route carries the policy applied to a request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 116 found the mesh's proxy applies nothing to a request — host lookup, forward. Against what the replaced ingress actually relies on, four capabilities are missing: authentication (three dependents, each gating an admin surface with no login of its own), refusal scoped to a path (one, a live incident mitigation), path-scoped routing with priority, and redirect. Policy goes on the route rather than beside it. A proxy-side settings layer keyed by route name would keep the grant literally clean, but then "what protects this route" is answered from two files nothing keeps in step — and a route's protection is part of what a route is. The set is closed at those four, so a fifth is an amendment and each addition is earned by a dependent that exists. An open middleware surface was rejected: it recreates what is being replaced, and narrowing one later is far harder than widening a closed one. Where policy needs a credential the declaration names a secret and never carries the value, which keeps the existing secret machinery the only thing holding credentials. Inlining a hash was rejected as the first credential in a declaration — a precedent easier to set than withdraw. This re-keys the routing table by host and path with priority, which follows from the decision rather than being a separate one: two of the four need one host routed more than one way. Equal priorities must resolve identically every time or the proxy stops being reproducible. The record says how it is checked, including the negative case that rots quietly — a declaration carrying a credential value rather than a reference must be refused, so the rejected option cannot return by accident. 08-connectivity §3 names the record and gains the subsection; issue 116 gains amended-design. --- ...carries-the-policy-applied-to-a-request.md | 102 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/08-connectivity.md | 36 ++++++- .../00-report.md | 2 +- 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md diff --git a/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md b/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md new file mode 100644 index 0000000..d7e46d3 --- /dev/null +++ b/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md @@ -0,0 +1,102 @@ +--- +topic: the tiers +status: accepted +date: 2026-09-25 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0007-connectivity.md +--- + +# 108. A route carries the policy applied to a request, and names a secret rather than holding one + +## Context + +[ADR 0007](0007-connectivity.md) settled that **a route is a grant**: a module contributes the name +it wants and the port it listens on, and the proxy hands back the public name. That governs the +**grant**. It says nothing about what a request arriving at the name is permitted to do, and the +mesh's proxy currently permits everything: its request path is a host lookup and a forward, with no +authentication, no source check, no redirect handling and no middleware anywhere in it. + +The standing requirement is that the mesh does **at minimum** what the system it replaces already +does. Measured against the predecessor's live configuration and its module catalogue +([issue 116](../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)), four +capabilities are relied on and absent: + +| Capability | Dependents, counted | +|---|---| +| Authentication | **three** modules, each gating a credential-less admin surface — a key-value browser UI, a **database web UI**, and the replaced ingress's own dashboard | +| Refusal scoped to a path | **one**, and it is a live **incident mitigation** closing a write primitive that was abused | +| Path-scoped routing with priority | **two** — the refusal above, and a certificate-challenge path on a host that otherwise routes elsewhere | +| Redirect | **two** live routes canonicalising a `www` name onto its apex | + +Counting needed two sources and neither alone is complete: the catalogue cannot see what was +hand-written on a node, and a node's configuration directory cannot see what modules declare as +container labels. An earlier count read one source and undercounted authentication by two. + +**The third row is a prerequisite, not a sibling.** The proxy's table maps a host to exactly one +target, so a host cannot be routed two ways. Adding authentication and a source filter would not +make the refusal rule expressible. + +## Considered Options + +1. **Leave policy out of the mesh; keep the affected routes on the adopted ingress.** *Rejected.* + The mesh would run two reverse proxies indefinitely with no principled division between them, and + one of the routes held back is a live mitigation — leaving it on a component being decommissioned + means its removal date is whenever somebody forgets. +2. **A separate, proxy-side settings layer keyed by route name.** *Rejected.* It keeps the grant + literally clean, but answering *"what protects this route"* then requires reading two files that + nothing keeps in step. A route's protection is part of what a route is. +3. **The grant carries a reference; the detail lives in a second layer.** *Rejected.* Both costs of + option 2, plus a naming indirection to maintain. +4. **Carry the password hash in the declaration.** *Rejected.* It would be the first credential + value in a declaration, and a precedent is easier to set than to withdraw. A hash is not a + plaintext password, but it is sufficient to pass the gate it protects. +5. **An open middleware surface the proxy applies.** *Rejected.* It recreates the thing being + replaced, makes the proxy's behaviour unbounded, and an open surface is far harder to narrow later + than a closed one is to widen. + +## Decision + +**A route contribution carries the policy applied to requests arriving at its name**, alongside the +name, the port and the location it already carries. + +**The set is closed, and it is these four:** authentication; refusal scoped to a path; path-scoped +routing with priority; redirect. A fifth is an amendment to this record, deliberately — each +addition should be earned by a dependent that exists. + +**Where policy needs a credential, the declaration names a secret the mesh mints and holds. It never +carries the value.** This keeps the existing secret machinery as the only thing that holds +credentials, and keeps hashes out of anything regenerated, synced or committed. + +**Consequently the routing table is keyed by host and path, with priority** — not by host alone. +This follows from the decision rather than being a separate one: two of the four capabilities need a +single host routed more than one way. + +## Consequences + +**What this makes possible.** The affected routes can leave the adopted ingress, and "at minimum" +becomes a satisfiable claim rather than a standing exception. The incident mitigation gets a durable +home in the mesh, which its own note already asked for. + +**What got harder.** The contribution shape grows, and every provider of `route` must understand +more of it. The table is no longer a flat map, and priority introduces ordering that has to be +deterministic rather than incidental — equal priorities must resolve the same way every time or the +proxy becomes non-reproducible. A closed set means a new need is a decision, not a patch. + +**What does not change.** The proxy remains a reference implementation: the contract is the file the +mesh writes, not the program that reads it. Another proxy may implement the same file. + +**How this is checked.** A rule states how it is verified, so this one does. A lab bed must show, +against a mesh that declared them: a route with authentication refusing an unauthenticated request +and admitting an authenticated one; a path-scoped refusal shadowing an ordinary route on the same +host while that ordinary route still serves every other path; a redirect answering with the +redirect; and — the negative case, which is the one that rots quietly — **a declaration carrying a +credential value rather than a reference being refused**, so option 4 cannot return by accident. + +## References + +- [Issue 116](../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md) — the count, + the evidence, and the structural finding about the table. +- [ADR 0007](0007-connectivity.md) — a route is a grant. This record extends it to the request. +- [ADR 0009](0009-modules-and-the-graph.md) — the provision vocabulary a contribution belongs to. +- [To-be 08 §3](../03-DESIGN/01-to-be/08-connectivity.md) — where exposure is specified. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index e0ae9bd..f23c044 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -123,6 +123,7 @@ python3 00-META/checks/index.py fail if stale - **0094** — [A module may hold several secrets from one provider, each a pair of its own](0094-a-module-may-hold-several-secrets-from-one-provider.md) - **0095** — [The control plane is the way to ask a module](0095-the-control-plane-is-the-way-to-ask-a-module.md) - **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) +- **0108** — [A route carries the policy applied to a request, and names a secret rather than holding one](0108-a-route-carries-the-policy-applied-to-a-request.md) ### What runs on them, and how it gets there diff --git a/03-DESIGN/01-to-be/08-connectivity.md b/03-DESIGN/01-to-be/08-connectivity.md index fad77e3..ae7801f 100644 --- a/03-DESIGN/01-to-be/08-connectivity.md +++ b/03-DESIGN/01-to-be/08-connectivity.md @@ -7,11 +7,12 @@ code: - mesh-controller internal/identity/authority.go - mesh-host internal/identity/serving.go - mesh-host internal/apply (the service that reflects a rule set) -updated: 2026-09-23 +updated: 2026-09-25 decisions: - 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md + - 02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md - 02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md - 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md - 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md @@ -442,6 +443,39 @@ exactly the per-module cost it is meant to remove. The design is the composition stopgap until the manifest layer can carry a label and a domain separately ([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)). +### A route also carries what a request arriving at it may do + +*2026-09-25, from comparing the mesh's proxy against the ingress it would replace +([issue 116](../../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)), +decided in [ADR 0108](../../02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md).* + +A grant hands back a name. It did not say what the name admits, and the proxy admitted everything — +its request path was a host lookup and a forward. Measured against what the replaced ingress +actually relies on, four things were missing: **authentication**, **refusal scoped to a path**, +**path-scoped routing with priority**, and **redirect**. Three modules depend on the first, each to +gate an admin surface that has no login of its own; one dependent of the second is a live incident +mitigation. + +**Policy belongs to the route, not beside it.** A contribution carries it along with the name, the +port and the location. The alternative — a proxy-side settings layer keyed by route name — keeps the +grant literally clean but makes *"what protects this route"* a question answered from two files that +nothing keeps in step. A route's protection is part of what a route is. + +**The set is closed at those four.** A fifth is an amendment, so each addition is earned by a +dependent that exists rather than added because a middleware surface was open. An open surface would +recreate the thing being replaced, and is far harder to narrow later than a closed one is to widen. + +**Where policy needs a credential, the declaration names a secret; it never carries one.** The mesh +already mints and holds credentials, and that machinery stays the only thing that does — so a hash +never reaches anything regenerated, synced or committed. + +**This re-keys the table.** Two of the four need one host routed more than one way, so the proxy +matches on host **and path**, with priority, rather than mapping a host to a single target. Equal +priorities must resolve identically every time, or the proxy stops being reproducible. + +The proxy remains a reference implementation: the contract is the file the mesh writes, not the +program that reads it, and another proxy may implement the same file. + ## 4 — Filtering **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md index cecdf46..952cd86 100644 --- a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -3,7 +3,7 @@ status: located opened: 2026-09-25 located-in: [mesh-controller examples/route-proxy] fixed-by: -amended-design: +amended-design: 03-DESIGN/01-to-be/08-connectivity.md --- # 116 — The mesh's proxy applies no policy to a request: no authentication, no source restriction, no path scoping, no redirects