ADR 0002: One shared token, and a bind that refuses
Date: 2026-08-02 Status: accepted Supersedes: ADR 0001 decision 4
Context
ADR 0001 decision 4 said there is no authentication, and gave the right reason: RBAC protects a shared deployment, and this was a single operator on a laptop. Nothing about that was wrong when it was written.
Then this started running in a container with a published port, and the API became the only way in for a deployment nobody can shell into. At that point the thing standing between the inventory and whoever can reach the port was nothing at all. The decision had not been revisited; the deployment had changed underneath it.
The inventory is not public data. It names products, the teams accountable for them, where their code is hosted, which are unowned, and which have no SBOM. That last set is a list of where to look first, which is exactly the list not to publish.
Decisions
1. One shared token, not user accounts
CRANE_API_TOKEN, presented as Authorization: Bearer <token> or X-API-Token.
Comparison is constant time. Failures say “invalid token” and nothing else, because a
message distinguishing “no token” from “wrong token” helps a guess.
Rationale: the question this has to answer is “may you reach this at all”, not “which rows may you see”. Accounts, roles and per subject permissions answer a question nobody has asked yet, and each one is a thing to get wrong. A single token is small enough to be understood completely, which a model nobody understands is not.
What this deliberately does not give: it cannot tell two people apart. Attribution therefore still comes from the actor named on the write, exactly as decision 4 established and for the same reason — the audit trail is the product. A token proves somebody may write; the name on the decision says who did.
2. Browsers exchange the token for a session cookie
A person types the token into a form once and gets an httponly, samesite lax cookie. Anything that is not a browser sends the header every time.
Rationale: a header is right for a script and impossible for a person. samesite=lax
means another site cannot post to this one with the cookie attached, which is the CSRF
question, and the answer arrives without a token round trip on every form.
Where a browser is sent to the login form, the return path is validated as a path on this
server. A next parameter forwarded unchecked sends somebody to another site immediately
after they type a password, and that the destination looks like this one is the trick.
3. An unauthenticated bind beyond localhost refuses to start
No token and a non local bind is a refusal, not a warning. CRANE_ALLOW_UNAUTHENTICATED_BIND
is the one explicit way past it, and every startup that uses it says so.
Rationale: exposing the whole inventory to a network by forgetting a variable should not be
possible, and a warning is a thing that scrolls past. The override exists because there is
one case the bind address cannot describe: in a container the server must bind 0.0.0.0
for a published port to reach it, and what actually restricts access is the port mapping on
the host. 0.0.0.0 there can be more restricted than 0.0.0.0 on a laptop, and this
process cannot tell the two apart. The override is somebody asserting that something
outside this process restricts the port. It is deliberately long and deliberately explicit.
4. Health is public, everything else is not
/healthz says the process is up and nothing else. The login form and the stylesheet it
needs are reachable unauthenticated because they have to be.
Rationale: a health check that requires a secret cannot be used by the thing that restarts the container. It is safe only because it says nothing about the data, which is a property to keep rather than a detail.
Consequences
- The laptop case is unchanged: no token and a localhost bind is still open, still the
default, and still what
crane servedoes with no arguments. - There is no way to revoke access for one person without rotating the token for everyone. That is the cost of decision 1 and it is acceptable while the operators are a handful of people who already share access to the machine. It stops being acceptable the moment this is offered to a division rather than a team, and that is the trigger to revisit, not a general wish for accounts.
- The token is in an environment variable, so it is visible to anything that can read the process environment on that host. This is not a defence against somebody already on the machine, and it is not meant to be.
- Nothing in the model changed.
tenant_idstill carries no enforcement (decision 5), and this ADR does not move toward multi tenancy; it is one door with one key, not isolation.