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