identity contract: neutral headers, and the chain is not one hop
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.
This commit is contained in:
+108
-56
@@ -9,6 +9,7 @@ it from learning anything else.
|
|||||||
| Status | **Specified, not implemented.** See §8. |
|
| Status | **Specified, not implemented.** See §8. |
|
||||||
| Audience | This project, and whoever builds the membership system |
|
| Audience | This project, and whoever builds the membership system |
|
||||||
| Companions | `PROCESS.md`, `STAGING-STATE.md`, `deploy/README.md` |
|
| 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
|
very long time. An interface that is small enough to state on one page is the
|
||||||
only kind that survives that.
|
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
|
## 1. The shape: relying party, not integration
|
||||||
|
|
||||||
**The compiler does not authenticate anyone. It is told.**
|
**The compiler does not authenticate anyone. It is told.**
|
||||||
|
|
||||||
Authorisation is decided by the reverse proxy on CT 101, against the membership
|
Authorisation is decided upstream, against the membership system. By the time a
|
||||||
system. By the time a request reaches the application, the decision is already
|
request reaches the application, the decision is already made. The application
|
||||||
made. The application reads an identity and acts; it never asks a question about
|
reads an identity and acts; it never asks a question about eligibility, because
|
||||||
eligibility, because it has nothing to ask the question with.
|
it has nothing to ask the question with.
|
||||||
|
|
||||||
This is the same rule the project applies to physical claims. `PRECISION.md`
|
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
|
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
|
never issues a verdict. "Who may do this" is a verdict. It belongs to whoever
|
||||||
holds the membership roll, and not here.
|
holds the membership roll, and not here.
|
||||||
|
|
||||||
The practical consequence is that the two systems share a hostname's parent
|
The practical consequence is that the two systems share nothing but a decision.
|
||||||
domain and nothing else. Either can be rewritten entirely without the other
|
Either can be rewritten entirely without the other being read.
|
||||||
being read.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,32 +65,53 @@ browser
|
|||||||
-> CT 100 :8770 the application
|
-> 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
|
**CT 101 does not know the public name.** Its `server_name` is
|
||||||
`mechanical-compiler.dev.infra` and its certificate is local. See
|
`mechanical-compiler.dev.infra` and its certificate is local. See
|
||||||
`deploy/README.md`.
|
`deploy/README.md`.
|
||||||
|
|
||||||
**The decision belongs at CT 101, never at `wg-pk`.** The hub carries twenty
|
**The decision belongs at the last hop, never at the hub.** `wg-pk` carries
|
||||||
peers, is estate infrastructure this project does not own, and every change
|
twenty peers, is estate infrastructure this project does not own, and every
|
||||||
there is an escalation under `PROCESS.md` §7. CT 101 already shares `vmbr1` with
|
change there is an escalation under `PROCESS.md` §7. Putting authorisation at
|
||||||
CT 100 and the membership container, so the eligibility check is a local call
|
the hub would make every future adjustment to who-may-do-what an estate change,
|
||||||
that never leaves the service bridge. Putting authorisation at the hub would
|
and would teach a shared transport about one project's membership roll. The hub
|
||||||
make every future adjustment to who-may-do-what an estate change, and would
|
stays transport.
|
||||||
teach a shared transport about one project's membership roll.
|
|
||||||
|
**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
|
## 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 |
|
| Header | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `X-Kane-Auth-Email` | The identity. An email address, which is the identifier. |
|
| `X-Mechcomp-Auth-Id` | The identity: a stable identifier for the person the decision was made about. |
|
||||||
| `X-Kane-Auth-Method` | How it was established, e.g. `kane-fabric/oidc`. Free text, recorded verbatim. |
|
| `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
|
||||||
|
`<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
|
are tested today. When they arrive, `verified` becomes true and the rendered
|
||||||
design record line changes from
|
design record line changes from
|
||||||
|
|
||||||
@@ -96,7 +122,7 @@ author someone@example.org (self-declared, unverified)
|
|||||||
to
|
to
|
||||||
|
|
||||||
```
|
```
|
||||||
author someone@example.org (verified: kane-fabric/oidc)
|
author someone@example.org (verified: <method, verbatim>)
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing in `design_record.py` changes shape. The parser already refuses to
|
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
|
because a file cannot attest to its own verification. That asymmetry was built
|
||||||
for this moment.
|
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
|
**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
|
**I-1. Exactly one hop decides, and it is the last one before the application.**
|
||||||
else.** nginx forwards headers it was given. Without the strip, any client can
|
Two intermediaries both setting `X-Mechcomp-Auth-*` is a forgery vector wearing
|
||||||
send `X-Kane-Auth-Email` and be whoever they like. This is the invariant the
|
the costume of a deployment change: the later one wins, the earlier one believes
|
||||||
other two exist to support, and it is worth landing before there is anything to
|
it decided, and nothing reports the conflict. However long the chain becomes,
|
||||||
forge against.
|
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.
|
`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:
|
`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
|
behind a proxy, binding too narrowly fails loudly as a 502, and binding too
|
||||||
widely fails silently as an open service nobody notices.
|
widely fails silently as an open service nobody notices.
|
||||||
|
|
||||||
**I-3. An absent header means unauthenticated. It is never an error and never a
|
**I-4. Anything that is not an affirmative permission is a refusal.** Unreachable,
|
||||||
default identity.** The composer must run from a bare checkout on a machine with
|
timed out, malformed, `5xx` — all deny. Fail-open and fail-closed are both
|
||||||
no proxy in front of it, and an anonymous visitor is an ordinary, expected
|
defensible and they are not the same system; discovering which one was built
|
||||||
caller. A missing header that produced a 500, or that silently became
|
during an outage is the worst way to find out. The consequence is that the
|
||||||
`admin@somewhere`, would be worse than no authentication at all.
|
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
|
/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,
|
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
|
no shared-infrastructure change, no escalation. Ungating one is the same move in
|
||||||
reverse.
|
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
|
This list is the boundary. It is short on purpose, and it is the part most
|
||||||
likely to erode.
|
likely to erode.
|
||||||
|
|
||||||
- **What a building is.** Membership attaches to buildings. The compiler has no
|
- **What a building is.** Membership attaches to buildings in the current
|
||||||
representation of one and must not acquire it.
|
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`
|
- **What a membership level is.** `CURRENT RESIDENT`, `HOA MEMBER`, `3D PRINTER`
|
||||||
— the compiler does not know these strings exist. By the time a request
|
— 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".
|
arrives, the level has already been resolved into "this request may proceed".
|
||||||
@@ -174,7 +220,7 @@ likely to erode.
|
|||||||
side door.
|
side door.
|
||||||
|
|
||||||
If the application ever needs one of these to answer a request, the design has
|
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 —
|
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
|
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
|
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
|
- **An eligibility endpoint** reachable from the deciding hop, which answers a
|
||||||
answers a request with `2xx` when the caller may proceed and `401` when not.
|
request with `2xx` when the caller may proceed and `401` when not. Whatever
|
||||||
Whatever session or token the caller carries is between that endpoint and the
|
session or token the caller carries is between that endpoint and the browser;
|
||||||
browser; CT 101 forwards and does not interpret.
|
the deciding hop forwards and does not interpret. Per I-4, anything else is a
|
||||||
- **Two response headers on a `2xx`**, carrying the address and the method, for
|
refusal.
|
||||||
CT 101 to promote onto the proxied request.
|
- **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
|
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
|
page and its storage are not this contract's concern and must not be constrained
|
||||||
constrained by it.
|
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
|
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
|
`/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
|
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.
|
membership system offers §7's endpoint.
|
||||||
|
|
||||||
An earlier version of `WORK-ORDER-004` and of `HANDOFF.md` §4 called the open
|
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
|
Developers*, and the name is on printed material. The question is carried openly
|
||||||
instead.
|
instead.
|
||||||
|
|
||||||
**I-1, the inbound strip, does not depend on any of this** and should land on
|
**I-2, the inbound strip, does not depend on any of this** and should land on its
|
||||||
its own. It costs one directive and removes a forgery that would otherwise
|
own. It costs one directive and removes a forgery that would otherwise become
|
||||||
become possible the moment §3 is implemented.
|
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
|
`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
|
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
|
user table, a login form, or a session. Authorisation lives upstream, and an item
|
||||||
the membership system, and an item leaves the roadmap rather than moving down
|
leaves the roadmap rather than moving down it.
|
||||||
it.
|
|
||||||
|
|
||||||
Persistence — the priority above it — changes shape rather than disappearing. A
|
Persistence — the priority above it — changes shape rather than disappearing. A
|
||||||
saved design belongs to a verified person, and the verified person now comes
|
saved design belongs to a verified person, and the verified person now comes from
|
||||||
from outside, so it should be built knowing the identity is external.
|
outside, so it should be built knowing the identity is external.
|
||||||
|
|||||||
Reference in New Issue
Block a user