ADR 015: Every record id from a request goes through an access scope¶
- Status: Accepted
- Date: 2026-10-26
- Deciders: Owner (plan
docs/plans/trip_replay_skipper_menus_plan.md, agreed 2026-10-26) - Technical Context:
apps/servercontrollers and models; enforced bylib/quality/checks/scoped_ids.rbandtest/integration/access_matrix_test.rb; rule inapps/server/AGENTS.md
Context¶
While planning trip roles, three places were found that took a record id from the request and used it without checking whose record it was:
| Where | What anyone could do | What it exposed |
|---|---|---|
PATCH /my/tracks/:id permitted track[trip_id] |
Move their own track into any trip by its id | Their track inside a stranger's trip, its map and totals, visible to that trip's crew and public link |
PATCH /my/trips/:id permitted trip[boat_id] |
Put another user's boat on their own trip | That boat's name, model, charter company and home port |
GET /support_requests/:id, public, counted ids |
Open /support_requests/1, /2, … without signing in |
Name and email of every sender |
All ids in the signed-in area were checked through current_user scopes in the URL, so the holes were in form fields and in one public page. The third was already listed as open question S2.
A read-only check of the production backup of 2026-10-25 (9 users, sign-up closed, invited users only) found no track in another user's trip, no trip with another user's boat and no support request stored at all. Nothing was exposed; no breach to report.
Decision¶
- A record id from a request (path, query or form) is only resolved through a scope of what the user may access:
current_user.trips.find,Trip.visible_to(current_user).find, later a role scope. NeverModel.find(params…)outside the admin area. - Strong params never permit a
*_idkey. The controller resolves each id on its own, through a scope, and answers 404 when it isn't found (as for a URL id). - Public pages find records by a random token (
/t/,/crew/) or a signed id (preview page), never by a counted id. The support confirmation keeps the sent request in the session instead of the URL. - Models check that linked records have the same owner: a track's trip and a trip's boat belong to the same user. A missed controller check then still fails.
- Enforced, not remembered:
bin/qualitycheckscoped_idsfails onpermit(with a:…_idkey and onModel.find(params/Model.find_by(id: params/Model.where(…_id: paramsin controllers outsideapp/controllers/admin/.test/integration/access_matrix_test.rbcalls every signed-in route with an id in its path as a stranger, using another user's records, and expects 404. Routes come fromconfig/routes.rb, so new routes are covered; a new kind of record needs one line, or the test fails and says so.- The same test covers the ids sent in forms (Move, boat, upload target).
- Considered and rejected: random UUIDs as the protection (ids leak through URLs and logs, and guessing is not the access check); Pundit or another authorization gem (the scopes already exist and stay explicit; trip roles get one permission table of their own).
Consequences¶
- The admin area may still find any record by id; it is for admins only (ADR 012).
- A form that really needs to point at another user's record (none today) needs a scope that says why, not an exception in the check.
- Old
/support_requests/<id>links answer 404.