Everything an operator needs was already recorded — the decision log, the ledger, the accounts table, the mailing list — and none of it was reachable without ssh and SQL. That is fine for one person on one box and stops being fine at the second of either.
So there is a console. It is the most privileged surface in the product, and the first thing decided about it was not what it shows.
Operators are configuration, not data
OPERATOR_EMAILS=colin@example.com
An environment variable. The only way to become an operator is a deploy.
The conventional answer is a column — accounts.is_operator, a boolean, set by
an existing operator through the UI. It is what everyone builds and it has a
property worth staring at: anything able to write to that table can grant
itself the console.
Not just an admin. A SQL injection anywhere in the application. A migration with
a wrong WHERE. A compromised endpoint that updates account fields. Any of
those becomes privilege escalation, because the privilege lives in the same
place as ordinary data and is protected by the same code.
With an allowlist in the environment there is no in-app path to operator, because there is no code that grants it. Not "the code is careful" — there is no function.
The cost, stated
Operators cannot be managed from the console. Adding one is an apply-env run.
For one operator that is obviously the right side of the trade. For fifteen it would not be, and the spec says to revisit the decision in the open rather than work around it — because the way this goes wrong is somebody adding a column "temporarily" and never writing down that the property was traded away.
404, not 403
For everyone who may not be here, signed in or not:
def _not_found() -> JSONResponse:
return JSONResponse({"detail": "Not Found"}, status_code=404)
A 403 tells someone the console exists and that their account is merely not on
the list. That is more than they need and more than the distinction is worth —
it converts "is there an admin panel" from a guess into a confirmed fact, and
the answer is the same either way for anyone who should not have it.
An empty allowlist admits nobody. Closed, not open, and there is a test for it, because "no operators configured" is exactly the state a fresh deployment is in and exactly the state where a permissive default would be most costly.
Read-only, asserted by the route table
for route in app_module.app.routes:
if getattr(route, "path", "").startswith("/api/admin"):
assert set(getattr(route, "methods", set())) <= {"GET", "HEAD"}
Not a code review convention. A test that walks the application's own routing
table, so adding a write endpoint under /api/admin fails the build.
That matters more than it sounds, because the pressure to add one is constant and each individual case is reasonable. Send this campaign. Adjust that balance. Revoke this key. Each is a small convenience and each ends the property.
The test does not forbid it. It makes ending the property a deliberate act with a red build in front of it, so the argument gets made rather than assumed.
Derived, never stored
Trial status is computed from the ledger: paying means a top-up exists,
trial means a grant and no top-up, new means neither.
Nothing sets a flag when somebody pays. A stored flag would be a second record of something the ledger already knows, and a second thing that can be wrong — the same argument as the balance being a cached sum rather than an authored number, and the same failure mode when it drifts: two answers, no way to tell which is right.
What it does not do
There is no impersonation, no "view as this account", no editing. Someone wanting to know what a customer sees has to ask them, which is slower and is also the correct amount of friction for reading somebody else's data.