There is a version of this decision in every application: how does the system know who the administrators are?
The usual answer is a column. accounts.is_operator, a boolean, toggled through
the UI by an existing operator. It is what most frameworks scaffold, it supports
managing operators in-app, and it puts the answer where all the other answers
about an account live.
We use an environment variable, and the argument is narrow enough to state in one sentence.
The sentence
A privilege stored as data can be granted by anything that can write that data.
Not just the admin UI. A SQL injection anywhere in the application. A migration
with a wrong WHERE clause. An endpoint that updates account fields and forgets
to allowlist which ones. A batch script that meant to fix email casing.
Each of those is an ordinary bug in ordinary code. With a column, each is also privilege escalation, because the privilege lives in the blast radius of every write path that touches accounts.
With an allowlist in the environment there is no in-app path to operator. Not "the path is guarded" — there is no function that grants it, so there is nothing to guard, bypass, or get wrong.
What it looks like
OPERATOR_EMAILS = {
e.strip().lower()
for e in os.environ.get("OPERATOR_EMAILS", "").split(",")
if e.strip()
}
async def operator(request: Request) -> dict | None:
account = await current_account(request)
if account is None:
return None
if str(account.get("email", "")).lower() not in OPERATOR_EMAILS:
return None
return account
Lowercased on both sides, because an email differing only in case is the same
person — the same reason accounts has a unique index on lower(email).
An empty allowlist admits nobody. Closed, not open, with a test for it, because that is the state every fresh deployment starts in.
The cost, which is real
Operators cannot be managed from the console. Adding one means editing a secret and running the credential workflow.
For one operator that is clearly right. For fifteen, with people joining and leaving, it would be tedious enough that somebody would build the column — and the failure mode is not building it, it is building it without recording that a property was traded away.
So the spec says: when it stops being the right side of the trade, this is a decision to revisit in the open rather than a limitation to work around. The sentence exists so the next person has somewhere to disagree rather than somewhere to route around.
The thing that actually bit
It shipped broken, and not in the way the design anticipated.
The console read OPERATOR_EMAILS. Nothing delivered it — the compose
passthrough and the deploy allowlist entry were both written and neither was
committed, because a git add listed explicit paths and deploy/ was not among
them. Then the credential workflow had no line to send it either.
So the allowlist was empty, the console admitted nobody, and that was correct behaviour for the wrong reason with no way to fix it from the outside.
The tests all passed. Thirty of them, about exactly this authorisation boundary — and every one monkeypatches the module attribute:
monkeypatch.setattr(admin, "OPERATOR_EMAILS", {"boss@example.com"})
They exercise the decision and never the delivery. Which is worth stating as a general rule, because it applies to every configurable thing: a test that patches a module attribute proves the logic reading it, and proves nothing about whether anything writes it.
The fix was a separate set of checks that compare the four places a setting has to appear — compose, allowlist, workflow environment, workflow send — and complain when they disagree. Configuration delivery turned out to need the same treatment as ledger signs: a property held by something mechanical rather than by remembering.