Make beacon monitor tokens always retrievable #4
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/monitor-token-always-retrievable"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Monitor tokens are readable again
The monitor token was reveal-once:
20260824120000_hash_beacon_monitor_tokensreplaced the column with a SHA-256 digest, so the plaintext existed only in the LiveView socket that minted it. An operator moving a station to new hardware — or re-flashing the one they have — had no way to read it back, and had to regenerate, knocking a working monitor offline to recover a credential that was never compromised.This stores the token itself (unique-indexed) and renders it on the monitor page on every visit. Rotation stays available, but as a deliberate act rather than the price of not having written the token down.
beacon_monitor.ex—:tokenis a real field again;token_hashis gonebeacon_monitors.ex—get_monitor_by_token/1matches the indexed column directly;regenerate_token/1is a plain updateMonitorLive.Show— identity panel always renders the token + Copy button.preserve_token/2andtoken_revealed?/1are deleted; they existed only because a reload returned nothingMonitorLive.New— the "shown only once" warning becomes an informational note pointing back at the monitor page⚠️ Deployed monitors need their new token
A digest is one-way, so no existing token is recoverable. The migration issues every row a fresh token, and every deployed monitor will 401 until its operator pastes the new value. This was a deliberate choice over leaving legacy rows showing "regenerate to recover".
Deliberately not changed
BeaconMonitorJSON.data/1still omits the token, so only the create response carries it.index/showare reachable by any scoped API token, whereas the monitor page requires a session.Accounts.UserApiToken) remain hashed. Separate system.wgrib2 suite is CI-only
Wgrib2Testshells out to the realwgrib2binary, which ships in the CI base image but is not on a typical dev machine — it contributed 12 failures to every local run, noise that buries real regressions.Tagged
@moduletag :wgrib2, excluded intest_helper.exsunless the binary is on PATH orCIis set. Sibling modules in that directory decode GRIB2 in pure Elixir and stay untagged.One wrinkle worth a look during review: gating on
$CIalone would have been wrong, because the workflow runsmix testinsidedocker runand the runner'sCIvariable does not reach the container — the suite would have silently skipped in CI too. So-e CI=trueis passed into the container, and awgrib2missing from the image now fails loudly instead of vanishing into 22 excluded tests.Verified both directions: default →
0 tests, 22 excluded;CI=true→ they run.Verification
mix format --check-formatted✅ ·mix credo --strict✅ ·mix xref --fail-above 4✅ ·mix compile --warnings-as-errors✅Full suite: 4881 passed, 6 skipped, 22 excluded, zero failures (was 12 failures at baseline, all wgrib2).
🤖 Skippy PR review
1 finding — none blocking.
lib/microwaveprop/beacon_monitors.ex:229Checked clean: migration up/down are symmetric and the ADD COLUMN's ACCESS EXCLUSIVE lock keeps a NULL token from slipping in mid-backfill; no remaining callers of the deleted
hash_token/1,token_revealed?/1orpreserve_token/2;BeaconMonitorJSON.data/1still omits the token andindex.exnever rendered it, so the only new read surfaces are the owner/admin-gated monitor page; prod log level is:infoand:filter_parameterscoverstoken, so the plaintext does not land in request or Ecto logs; the wgrib2 gate is the onlymix testinvocation in the workflow and it now getsCI=true.Reviewed
c91146c6c848. Commentskippy reviewto re-run.@ -224,3 +227,3 @@now = DateTime.utc_now(:second)case Repo.get_by(BeaconMonitor, token_hash: hash_token(token)) docase Repo.get_by(BeaconMonitor, token: token) do🟡 Warning — Raw bearer bytes now reach Postgres — a non-UTF-8 token 500s instead of 401ing
hash_token/1used to normalise whatever arrived in the header into a 32-byte digest before it ever touched the database. Now the client's bytes go straight into avarcharcomparison, and Postgres validates encoding on text parameters:Authorization: Bearer <invalid UTF-8>(scanner junk, a mangled paste, a truncated multibyte token) raisesPostgrex.Error22021invalid byte sequence for encoding "UTF8", soMicrowavepropWeb.Api.MonitorAuthcrashes and the unauthenticated ingest endpoint answers 500 rather than the 401 it is supposed to.Auth.extract_bearer/1only checks the"Bearer "prefix andbyte_size > 0, so nothing upstream filters it. One-line guard: