External review of bac6120 by the Kane Fabric project found two defects, both
mine, both cheap now and expensive once anything implements against them.
The contract gave kane-fabric/oidc as an example authentication method. That
named a capability in another project which has no OIDC service, no user
database and no person-authentication role at all. An example in an interface
document is read as an expectation by the next person to implement it -- the
same failure as "acceptable for a development name", a plausible clause nobody
challenged hardening into a constraint. Method is now specified by shape rather
than by example, and the document names no system outside itself.
The headers were X-Kane-Auth-Email and X-Kane-Auth-Method. Two things wrong: a
jurisdiction in a header name is a deployment fact in an invariant place, in a
document that spends a section insisting the compiler must never learn a
deployment's membership concepts; and -Email named a format in a field the
contract explicitly allows to hold something else. Now X-Mechcomp-Auth-Id and
X-Mechcomp-Auth-Method. The receiver is the invariant, the jurisdiction is not.
Section 3 now says plainly that the identifier need not be an email address. An
opaque or epoch-scoped token fits Author.email without a schema change; the
field is named for what this deployment holds, not for what the contract
requires. Renaming it would be a format change to every stored record and is
deliberately not done.
Two new invariants, both from taking seriously that containers owned by other
projects will insert themselves into this chain.
I-1: exactly one hop decides, and it is the last before the application. Two
intermediaries both setting the identity headers is a forgery vector wearing
the costume of a deployment change -- the later wins, the earlier believes it
decided, nothing reports the conflict. CT 101 is named in section 2 because it
is what exists, not because it is the invariant.
I-4: anything that is not an affirmative permission is a refusal. Unreachable,
timed out, malformed and 5xx all deny. Fail-open and fail-closed are both
defensible and are not the same system; finding out which one was built during
an outage is the worst way to learn it.
Section 6 gains parcels and delivery points explicitly, so the boundary holds
whichever primitive the geography layer settles on. Section 7 no longer obliges
any named project to provide anything -- the authorisation decision belongs to
a membership system between geography and this application, and nothing here
asks a geography layer to become an identity provider.
289 lines
12 KiB
Markdown
289 lines
12 KiB
Markdown
# IDENTITY-CONTRACT.md
|
|
|
|
How the Mechanical Compiler learns who a visitor is, and the boundary that keeps
|
|
it from learning anything else.
|
|
|
|
| | |
|
|
|---|---|
|
|
| Created | 2026-09-12 |
|
|
| 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` |
|
|
|
|
---
|
|
|
|
## 0. Why this document exists separately
|
|
|
|
It is the only document in this repository written to be read by someone who
|
|
does not work on this repository.
|
|
|
|
The membership system is a different application, in a different container, with
|
|
a different release schedule and a different author. Two systems that must agree
|
|
on an interface need that interface written down once, somewhere both of them
|
|
can point at. Burying it in `ENVIRONMENT.md` — which describes how *this* host
|
|
is built — guarantees the other side never finds it.
|
|
|
|
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 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 nothing but a decision.
|
|
Either can be rewritten entirely without the other being read.
|
|
|
|
---
|
|
|
|
## 2. The chain as it actually is
|
|
|
|
```
|
|
browser
|
|
-> wg-pk public TLS for dev.mechcomp.kane-il.us
|
|
-> WireGuard tunnel
|
|
-> DNAT on srv-b scoped to the hub as source
|
|
-> CT 101 nginx local staging-CA TLS; DECIDES; sets the headers
|
|
-> CT 100 :8770 the application
|
|
```
|
|
|
|
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 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 the deciding hop, and nothing else.
|
|
|
|
| Header | Meaning |
|
|
|---|---|
|
|
| `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. |
|
|
|
|
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
|
|
`<issuer>/<mechanism>`, 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
|
|
|
|
```
|
|
author someone@example.org (self-declared, unverified)
|
|
```
|
|
|
|
to
|
|
|
|
```
|
|
author someone@example.org (verified: <method, verbatim>)
|
|
```
|
|
|
|
Nothing in `design_record.py` changes shape. The parser already refuses to
|
|
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 identifier and it does not travel in this contract.
|
|
|
|
---
|
|
|
|
## 4. Four invariants
|
|
|
|
**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. 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-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.
|
|
|
|
---
|
|
|
|
## 5. One gated prefix
|
|
|
|
Everything requiring membership lives under `/m/`. Everything else is public.
|
|
|
|
```
|
|
/ the composer page public
|
|
/api/build build and view a section public
|
|
/m/stl export members
|
|
/m/... anything gated later members
|
|
```
|
|
|
|
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.
|
|
|
|
That cheapness is the point. It means the placement of the line is a reversible
|
|
decision rather than a structural one.
|
|
|
|
**Where the line is today: export is gated; looking is not.** The catalogue and
|
|
the composer are open to anyone. What requires membership is producing an
|
|
artifact that carries a design record, because the record names an author and an
|
|
author only means something once somebody established who they are. The site is
|
|
the shopfront; the wall goes where the claim is made.
|
|
|
|
---
|
|
|
|
## 6. What the compiler must never be told
|
|
|
|
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 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".
|
|
- **The membership roll.** No lookup, no directory, no list of who exists.
|
|
- **Group names in any form**, including as a configuration value. A
|
|
`REQUIRED_GROUP` setting in the application would be the leak arriving by the
|
|
side door.
|
|
|
|
If the application ever needs one of these to answer a request, the design has
|
|
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
|
|
line of this repository being read.
|
|
|
|
---
|
|
|
|
## 7. What the deciding hop needs from upstream
|
|
|
|
Minimum, and deliberately not more. How it is implemented is that project's
|
|
business, and this document names no system that does not exist.
|
|
|
|
- **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 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).
|
|
|
|
---
|
|
|
|
## 8. Current state
|
|
|
|
**Not implemented.** Specified here so that it is designed once, before the
|
|
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 a
|
|
membership system offers §7's endpoint.
|
|
|
|
An earlier version of `WORK-ORDER-004` and of `HANDOFF.md` §4 called the open
|
|
service "acceptable for a development name". That reasoning was wrong:
|
|
`dev.mechcomp.kane-il.us` is production, `dev` abbreviates *Mechanical Compiler
|
|
Developers*, and the name is on printed material. The question is carried openly
|
|
instead.
|
|
|
|
**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.
|
|
|
|
---
|
|
|
|
## 9. What this removes
|
|
|
|
`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 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.
|