Nakodo has a small operator panel at /admin. Most of it is read-only: counts, a run log, support threads, inbound mail. One page is not, and it is the one page in the product where somebody can change a record that is not theirs: a customer's account.
There are five such actions, in a 177-line file, and the design question in all of them is not "may this person do it" but "where does the evidence go".
The gate, five times
// Operator actions on somebody else's account. Every one of them re-checks
// the allowlist itself: a Server Action is a POST endpoint, and the gate in
// the layout above it doesn't run when one is called directly.
That comment is the whole reason await requireAdmin() is the first statement of all five functions and not a thing the surrounding layout handles. A Server Action compiles to a POST to an id, and an id can be posted to from anywhere. A layout is a render concern, and nothing about rendering runs when the action is invoked. Whoever is thinking about this in a Next.js app for the first time: the gate goes in the action, every time, even when the only link to the action is on a page behind the same gate.
The gate itself is a hardcoded one-address allowlist that answers 404 rather than 403, which I have written about separately. The part that matters here is that it is cache()d, so five calls in one request are one session read.
Where the evidence goes
await audit({
action: "admin.plan_changed",
userId,
entityType: "user",
entityId: userId,
metadata: { from: before.plan, to: planId },
});
userId is the customer. Not the operator. Every operator action is recorded against the account it touched, which means it lands in the same place as that person's own history, and that place is readable by them: the account export hands over the activity log, and the privacy notice promises it does ("a log of account activity such as sign-up, accepting the terms, plan changes, exports and outreach you start, approve or stop").
So if I grant you a plan or edit your company name, you can download the row that says so. That was the whole intent and I would make the same choice again.
It has a consequence I did not intend, and reading the code for this post is how I noticed it. The audit helper derives the actor from whether a user id is present:
const row = (entry: AuditEntry) => ({
userId: entry.userId ?? null,
actor: entry.userId ? ("user" as const) : ("system" as const),
...
An operator action therefore stores actor: "user" with the customer's id. The only things in the row that say an operator was involved are the admin. prefix on the action name and, for exactly one of the five, a field in the metadata:
metadata: { by: operator.email },
That is on account deletion only. For the other four, the log records that an operator did something and never which one. With one operator that is a distinction without a difference. With two it is the difference between an audit log and a diary, and by costs one line in four places.
Comps are refused when Stripe is involved
// Grants or revokes a plan without payment, for a comp or a goodwill fix. An
// account that pays through Stripe is refused: the subscription is the source
// of truth and writing round it would be undone by the next webhook.
export async function setPlanAction(userId: string, planId: unknown): Promise<ActionResult> {
The refusal is not in the action, it is in the shared changePlan:
if (hasLiveStripeSubscription(before)) {
throw new UserError("This account pays through Stripe. Change or cancel the subscription there.");
}
Which is the right place for it, because the same rule has to hold for the customer's own plan screen. The reasoning is worth spelling out for anybody wiring a payment provider into their own plan column: a comp is a write to our row, a subscription is a fact in Stripe's system, and the webhook's job is to make our row agree with Stripe. Write a comp over a live subscription and the next customer.subscription.updated silently reverts it, which is the worst possible outcome because the operator saw it work.
A comp also writes two audit rows, not one. changePlan writes plan.changed with metadata.billing = "granted", and the action adds admin.plan_changed on top. The first of those is in the set forwarded to analytics, so a comped upgrade appears in the funnel next to bought ones, distinguishable only by that billing property. We rely on exactly that property elsewhere, to tell whether an account ever actually paid, so the honest version of this is: a comp is a plan change that happens to have been free, and it is tagged, not hidden.
An upgrade, comped or paid, then widens whatever campaigns were narrowed by the smaller plan. An operator granting a plan is not a billing edit, it is a change in what the product will do tonight.
The two actions that say "only ever used to honour a request"
// Marketing consent, changed by hand. Only ever used to honour a request made
// some other way, "take me off the list" in a reply, which is why turning it
// ON is recorded just as loudly as turning it off.
Turning consent on by hand is the operation that needs the audit row, and consent systems often log only the removal. Ours writes the same row either way, against the email address rather than the user id, because consent belongs to an address and not to an account.
// Sends the account a password reset link, for someone who can't get one
// themselves. It goes to their address, never to the operator.
export async function sendResetLinkAction(email: unknown): Promise<ActionResult> {
await requireAdmin();
const to = z.email().safeParse(email);
...
}
This is the one with a hole in it. The action is correct about the thing people get wrong, which is that an operator-triggered reset must go to the account's own address and never produce a link the operator can see. What it does not do is write an audit row. Four of the five operator actions are in the log; "I asked the auth provider to email you a password reset" is not. It is the lowest-risk of the five and it is also the one a customer is most likely to ask about later, so it is now on the list to fix.
Erasure asks you to type it
const typed = formText(form, "email").email.toLowerCase();
const [account] = await db.execute<{ email: string | null }>(
sql`select email from auth.users where id = ${userId}::uuid`,
);
if (!account) return fail("That account is already gone.");
if (!typed || typed !== (account.email ?? "").toLowerCase()) {
return fail("Type the account's own email address exactly to confirm.");
}
The comment explains why the confirmation is not a yes/no dialog:
// The typed address must match the account's own, because an operator is
// working from a support thread and pasting the wrong ID is easy.
A confirm dialog defends against a misclick. Typing the address defends against being on the wrong record, which is the actual failure mode when the work arrives as "please delete my account" in an email thread and the id came from a search box.
Then the order: cancel in Stripe, write the audit row, delete the user. Stripe first because a deleted account with a live subscription keeps charging somebody who no longer exists to us, and a failure there aborts the whole thing with nothing deleted. The audit row before the delete, because the row's own user reference is on delete set null: it survives the account as an ownerless record that something happened.
What the profile edit chooses not to keep
changed = Object.entries(values)
.filter(([k, v]) => (before?.[k as keyof typeof values] ?? null) !== v)
.map(([k]) => k);
...
metadata: { fields: changed },
The metadata holds the names of the fields that changed, and not their values. So the log says "an operator changed your company name" and never says what it was before. That is deliberate, because an audit log that stores old values is a second copy of personal data with a different retention story. The cost is that the log cannot answer "what did it say before", and for a profile edit that is the right trade. For the plan change, where the values are two short enum strings that are not personal data, we do keep from and to.
Five actions, one allowlist checked five times, four audit rows, one missing, and exactly one of them knowing who the operator was. The useful lesson is not the gate: gates are easy. It is that "who did this" and "whose record is this" are two different columns, and if you only have one of them you will find out which one you needed at the worst possible moment.
Top comments (0)