Audit balance administration - #90
Conversation
Closes #68. A player spending currency was audited as `purchase`. An admin creating currency out of nothing was recorded only in the economy ledger — so the higher-privilege action of the two was the one missing from the global trail, and an operator reviewing the audit log for suspicious admin activity would not see balance administration at all. The entry is written inside addBalance/setBalance rather than at each call site. Two reasons. It is the only place that knows what actually happened: addBalance clamps at zero, so an admin asking to remove 500 from a balance of 300 removes 300, and an entry claiming 500 would be a false record. And there are three callers now — desktop IPC, the web panel, and an API key since #85 — which is three chances to drift if each writes its own. Source is passed by the caller so the three stay distinguishable: `panel` for the desktop, `webpanel` for a session, `api` for a key. "Which integration created a million coins" is the question that gets asked later. Refusals are recorded with ok:false. An admin action aimed at something that is not a valid Minecraft username is either a typo or somebody probing, and both are worth being able to find. The ledger keeps its own copy and was not replaced — the two answer different questions, and #68 was a discoverability gap, not an attribution one. docs/audit-trail.md described this as a known gap; that section is now the documented behaviour, and the action table also picks up the apikey.* and api.* entries added in #85, which it was missing. Verified with MSMS_SMOKE_WEB: all three kinds present and attributed, server id and source correct per surface, a refusal recorded as a failure, an API key distinguishable from a human session, and the recorded delta being the applied one (-300 -> 0) rather than the requested -500. MSMS_SMOKE and MSMS_SMOKE_AUDIT also green.
…forgettable Two things the review turned up, both the same shape as #68 itself. **A purchase made from the admin panel was not audited.** The public site's buy route records `purchase`; `/api/servers/:id/store/buy` did not. So whether the same action appeared in the trail depended on which page it was made from - which is exactly the inconsistency #68 exists to close, one route over. Actor is the panel account that clicked Buy, with the Minecraft name it delivered to in the detail, because those are not always the same person's identity. **The audit source had a default, which is a mis-attribution waiting to happen.** `source: AuditSource = 'panel'` meant any future caller that forgot the argument would be recorded as the local desktop operator. A missing audit entry is a gap; a confidently wrong one is a lie, and the second is worse. Making it required exposed that it could not simply be un-defaulted - a required parameter cannot follow the optional `by`/`reason`/`category`. So the tail becomes one `BalanceChange` object with `by` and `source` both required and named. Those are the two fields an audit entry cannot be reconstructed without, and now the type will not let a caller omit either. Twelve call sites updated; the compiler found all of them, which is the point. All twelve smoke gates pass.
Self reviewTwo findings, both the same shape as #68 itself — an economy action audited on 1. A purchase from the admin panel was not auditedThe public site records // handlePublic, sub === 'store/buy'
audit.record({ source: 'public', action: 'purchase', actor: player.mcName, … })
Actor is the panel account that clicked Buy, with the Minecraft name it was 2. The audit source had a defaultsource: AuditSource = 'panel'Any future caller that forgets the argument gets recorded as the local desktop Un-defaulting it did not compile — a required parameter cannot follow the export interface BalanceChange {
by: string // required
source: AuditSource // required
reason?: string
category?: string
}
Verified the new assertion can failBoth previous PRs shipped a test that could not fail, so I checked this one Reviewed and deliberately left alone
|
Closes #68.
A player spending currency was audited as
purchase. An admin creatingcurrency out of nothing was recorded only in the economy ledger — so the
higher-privilege action of the two was the one missing from the global trail,
and an operator reviewing the audit log for suspicious admin activity would not
see balance administration at all.
action: 'balance.set'appeared in the codebase only inside smoke fixtures,which is exactly what made it look covered when I documented it in #22.
Where the write goes, and why
Inside
addBalance/setBalance, not at the call sites the issue suggested.It is the only place that knows what happened.
addBalanceclamps at zero,so an admin asking to remove 500 from a balance of 300 removes 300. An entry
recording the requested amount would be a false record, and a false audit
entry is worse than a missing one.
detailcarries the applied delta and theresulting balance:
-300 -> 0 (correction).There are three callers now, not two. Desktop IPC, the web panel, and — since
#85 — an API key. Three call sites writing their own entry is three chances to
drift.
Source is passed by the caller so the three stay distinguishable:
panelforthe desktop,
webpanelfor a session,apifor a key. "Which integrationcreated a million coins" is the question that gets asked later.
Refusals
Recorded with
ok: falseanddetail: 'invalid-mcname'. An admin action aimedat something that is not a valid Minecraft username is either a typo or somebody
probing, and both are worth being able to find.
What did not change
The ledger keeps its own copy. The two answer different questions — the ledger is
per-server balance history that renders without a join, the audit trail is the
global record of privileged actions — and #68 was a discoverability gap, not
an attribution one. No data moved.
Docs
docs/audit-trail.mdhad a "Known gap" section for this; it is now thedocumented behaviour, including why the delta is the applied one. The action
table also picks up the
apikey.*andapi.*entries added in #85, which itwas silently missing.
Verification
MSMS_SMOKE_WEB,MSMS_SMOKE,MSMS_SMOKE_AUDIT— all exit 0.Asserted:
balance.grant/.remove/.set) present, with actor andserverIdwebpanelfrom a session,panelfrom the desktop,apifrom a key(and the key entry's actor starts with
key:)removing 500 audits
-300 -> 0, not-500