diff --git a/docs/IDENTITY-CONTRACT.md b/docs/IDENTITY-CONTRACT.md index 6f08dae..691a74a 100644 --- a/docs/IDENTITY-CONTRACT.md +++ b/docs/IDENTITY-CONTRACT.md @@ -9,6 +9,7 @@ it from learning anything else. | Status | **Specified, not implemented.** See §8. | | Audience | This project, and whoever builds the membership system | | Companions | `PROCESS.md`, `STAGING-STATE.md`, `deploy/README.md` | +| Open concerns | `CONSUMER_INTERFACE_GATES.md` | --- @@ -27,25 +28,29 @@ The compiler is expected to catalogue a very large number of applications over a very long time. An interface that is small enough to state on one page is the only kind that survives that. +**This document says what crosses the boundary. It does not say what anyone +outside this repository must build.** Where a concern has surfaced that is not +settled, it is recorded in `CONSUMER_INTERFACE_GATES.md` rather than resolved +here by assertion. + --- ## 1. The shape: relying party, not integration **The compiler does not authenticate anyone. It is told.** -Authorisation is decided by the reverse proxy on CT 101, against the membership -system. By the time a request reaches the application, the decision is already -made. The application reads an identity and acts; it never asks a question about -eligibility, because it has nothing to ask the question with. +Authorisation is decided upstream, against the membership system. By the time a +request reaches the application, the decision is already made. The application +reads an identity and acts; it never asks a question about eligibility, because +it has nothing to ask the question with. This is the same rule the project applies to physical claims. `PRECISION.md` puts codes and permitting out of scope — the compiler records what is true and never issues a verdict. "Who may do this" is a verdict. It belongs to whoever holds the membership roll, and not here. -The practical consequence is that the two systems share a hostname's parent -domain and nothing else. Either can be rewritten entirely without the other -being read. +The practical consequence is that the two systems share nothing but a decision. +Either can be rewritten entirely without the other being read. --- @@ -60,32 +65,53 @@ browser -> CT 100 :8770 the application ``` -Two things about this that are easy to get wrong: +Three things about this that are easy to get wrong: **CT 101 does not know the public name.** Its `server_name` is `mechanical-compiler.dev.infra` and its certificate is local. See `deploy/README.md`. -**The decision belongs at CT 101, never at `wg-pk`.** The hub carries twenty -peers, is estate infrastructure this project does not own, and every change -there is an escalation under `PROCESS.md` §7. CT 101 already shares `vmbr1` with -CT 100 and the membership container, so the eligibility check is a local call -that never leaves the service bridge. Putting authorisation at the hub would -make every future adjustment to who-may-do-what an estate change, and would -teach a shared transport about one project's membership roll. +**The decision belongs at the last hop, never at the hub.** `wg-pk` carries +twenty peers, is estate infrastructure this project does not own, and every +change there is an escalation under `PROCESS.md` §7. Putting authorisation at +the hub would make every future adjustment to who-may-do-what an estate change, +and would teach a shared transport about one project's membership roll. The hub +stays transport. + +**The chain is expected to grow.** Additional containers, owned by other +projects, may insert themselves between the browser and this application. CT 101 +is named above because it is what exists today — it is not the invariant. The +invariants are in §4 and are written as properties of the chain, not duties of a +named container. --- ## 3. What crosses the boundary -Two request headers, set by CT 101, and nothing else. +Two request headers, set by the deciding hop, and nothing else. | Header | Meaning | |---|---| -| `X-Kane-Auth-Email` | The identity. An email address, which is the identifier. | -| `X-Kane-Auth-Method` | How it was established, e.g. `kane-fabric/oidc`. Free text, recorded verbatim. | +| `X-Mechcomp-Auth-Id` | The identity: a stable identifier for the person the decision was made about. | +| `X-Mechcomp-Auth-Method` | How it was established. Recorded verbatim, never interpreted. | -They map onto `design_record.Author.email` and `Author.method`, which exist and +The headers name the *receiver*, not any deployment. A jurisdiction, a domain or +an operator in a header name would be a deployment fact in an invariant place. + +**The identifier is not required to be an email address.** An address is what the +current deployment uses. An opaque or epoch-scoped token is equally valid and +needs no change here. The application stores the string, renders it, and makes no +claim about its form. See `CONSUMER_INTERFACE_GATES.md` G-3, where the tension +between durable provenance and non-durable identity is recorded and left open. + +**Method has a shape, not a vocabulary.** Enough to say who established the +identity, by what mechanism, and what was actually checked — for example +`/`, with whatever detail the issuer considers meaningful. No +value is enumerated here, because this document refuses to know what mechanisms +exist upstream. It is recorded verbatim so that a reader years from now sees what +was claimed rather than this project's interpretation of it. + +These map onto `design_record.Author.email` and `Author.method`, which exist and are tested today. When they arrive, `verified` becomes true and the rendered design record line changes from @@ -96,7 +122,7 @@ author someone@example.org (self-declared, unverified) to ``` -author someone@example.org (verified: kane-fabric/oidc) +author someone@example.org (verified: ) ``` Nothing in `design_record.py` changes shape. The parser already refuses to @@ -104,30 +130,48 @@ recover `verified` from text — a record read back is always self-declared, because a file cannot attest to its own verification. That asymmetry was built for this moment. +`Author.email` is named for what it holds in this deployment, not for what the +contract requires. Renaming it is a format change to every stored record and is +deliberately not done. + **A handle is not the identity.** A person may later choose a display name. It -does not replace the address and it does not travel in this contract. +does not replace the identifier and it does not travel in this contract. --- -## 4. Three invariants, all of them CT 101's job +## 4. Four invariants -**I-1. Inbound `X-Kane-Auth-*` is stripped at every location, before anything -else.** nginx forwards headers it was given. Without the strip, any client can -send `X-Kane-Auth-Email` and be whoever they like. This is the invariant the -other two exist to support, and it is worth landing before there is anything to -forge against. +**I-1. Exactly one hop decides, and it is the last one before the application.** +Two intermediaries both setting `X-Mechcomp-Auth-*` is a forgery vector wearing +the costume of a deployment change: the later one wins, the earlier one believes +it decided, and nothing reports the conflict. However long the chain becomes, +one hop holds this responsibility. -**I-2. CT 100 is reachable only from CT 101.** The application binds +**I-2. Inbound `X-Mechcomp-Auth-*` is stripped at every location, before +anything else.** A proxy forwards headers it was given. Without the strip, any +client can send an identity header and be whoever it likes. This is the +invariant the others exist to support, and it is worth landing before there is +anything to forge against. + +**I-3. The application is reachable only from the deciding hop.** It binds `10.20.0.10:8770` on a portless service bridge with LAN traffic dropped. `web/app.py` defaults to loopback rather than `0.0.0.0` for the same reason: behind a proxy, binding too narrowly fails loudly as a 502, and binding too widely fails silently as an open service nobody notices. -**I-3. An absent header means unauthenticated. It is never an error and never a -default identity.** The composer must run from a bare checkout on a machine with -no proxy in front of it, and an anonymous visitor is an ordinary, expected -caller. A missing header that produced a 500, or that silently became -`admin@somewhere`, would be worse than no authentication at all. +**I-4. Anything that is not an affirmative permission is a refusal.** Unreachable, +timed out, malformed, `5xx` — all deny. Fail-open and fail-closed are both +defensible and they are not the same system; discovering which one was built +during an outage is the worst way to find out. The consequence is that the +eligibility endpoint is a hard dependency of every gated route, which is recorded +as an open concern rather than designed around (`CONSUMER_INTERFACE_GATES.md` +G-5). + +**And a fifth that belongs to the application:** an absent header means +unauthenticated. It is never an error and never a default identity. The composer +must run from a bare checkout with no proxy in front of it, and an anonymous +visitor is an ordinary, expected caller. A missing header that produced a 500, or +that silently became somebody, would be worse than no authentication at all. --- @@ -142,7 +186,7 @@ Everything requiring membership lives under `/m/`. Everything else is public. /m/... anything gated later members ``` -nginx gets **one** `location /m/` block, written once and not edited again. +The proxy gets **one** `location /m/` block, written once and not edited again. Gating a new endpoint afterwards is choosing a URL in Python — no proxy change, no shared-infrastructure change, no escalation. Ungating one is the same move in reverse. @@ -163,8 +207,10 @@ the shopfront; the wall goes where the claim is made. This list is the boundary. It is short on purpose, and it is the part most likely to erode. -- **What a building is.** Membership attaches to buildings. The compiler has no - representation of one and must not acquire it. +- **What a building is.** Membership attaches to buildings in the current + design. The compiler has no representation of one and must not acquire it. +- **Parcels, delivery points, or any other geography.** Same rule, and it holds + regardless of which primitive the membership layer settles on. - **What a membership level is.** `CURRENT RESIDENT`, `HOA MEMBER`, `3D PRINTER` — the compiler does not know these strings exist. By the time a request arrives, the level has already been resolved into "this request may proceed". @@ -174,7 +220,7 @@ likely to erode. side door. If the application ever needs one of these to answer a request, the design has -failed and the fix is at the proxy, not here. +failed and the fix is upstream, not here. The test: the membership system must be able to restructure its entire model — rename every level, change what a building is, replace its storage — without a @@ -182,21 +228,28 @@ line of this repository being read. --- -## 7. What the membership system must provide +## 7. What the deciding hop needs from upstream Minimum, and deliberately not more. How it is implemented is that project's -business. +business, and this document names no system that does not exist. -- **An eligibility endpoint** reachable from CT 101 on the service bridge, which - answers a request with `2xx` when the caller may proceed and `401` when not. - Whatever session or token the caller carries is between that endpoint and the - browser; CT 101 forwards and does not interpret. -- **Two response headers on a `2xx`**, carrying the address and the method, for - CT 101 to promote onto the proxied request. +- **An eligibility endpoint** reachable from the deciding hop, which answers a + request with `2xx` when the caller may proceed and `401` when not. Whatever + session or token the caller carries is between that endpoint and the browser; + the deciding hop forwards and does not interpret. Per I-4, anything else is a + refusal. +- **Two response headers on a `2xx`**, carrying the identifier and the method, + for the deciding hop to promote onto the proxied request. That is the whole of it. The endpoint's path, its session mechanism, its login -page and its storage are not this contract's concern and should not be -constrained by it. +page and its storage are not this contract's concern and must not be constrained +by it. + +**This is not a requirement on any named project.** Nothing here obliges the +geography layer to become an identity provider, and it should not. The +authorisation decision belongs to a membership or participation system sitting +between geography and this application — a shape both projects arrived at +independently (`CONSUMER_INTERFACE_GATES.md` §4). --- @@ -208,7 +261,7 @@ endpoints that will depend on it exist. What is true today: the service is world-reachable and unauthenticated, and `/m/` is not yet gated. The first endpoint under it, STL export, will therefore ship open. That is a deliberate, recorded, temporary state and not an oversight -— it is the same posture the whole service already has, and it changes when the +— it is the same posture the whole service already has, and it changes when a membership system offers §7's endpoint. An earlier version of `WORK-ORDER-004` and of `HANDOFF.md` §4 called the open @@ -217,9 +270,9 @@ service "acceptable for a development name". That reasoning was wrong: Developers*, and the name is on printed material. The question is carried openly instead. -**I-1, the inbound strip, does not depend on any of this** and should land on -its own. It costs one directive and removes a forgery that would otherwise -become possible the moment §3 is implemented. +**I-2, the inbound strip, does not depend on any of this** and should land on its +own. It costs one directive and removes a forgery that would otherwise become +possible the moment §3 is implemented. --- @@ -227,10 +280,9 @@ become possible the moment §3 is implemented. `HANDOFF.md` §4 listed an access-control layer inside the compiler as the last priority. **It is deleted, not deferred.** The compiler will not have an ACL, a -user table, a login form, or a session. Authorisation lives at the proxy against -the membership system, and an item leaves the roadmap rather than moving down -it. +user table, a login form, or a session. Authorisation lives upstream, and an item +leaves the roadmap rather than moving down it. Persistence — the priority above it — changes shape rather than disappearing. A -saved design belongs to a verified person, and the verified person now comes -from outside, so it should be built knowing the identity is external. +saved design belongs to a verified person, and the verified person now comes from +outside, so it should be built knowing the identity is external.