Skip to content

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/server controllers and models; enforced by lib/quality/checks/scoped_ids.rb and test/integration/access_matrix_test.rb; rule in apps/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

  1. 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. Never Model.find(params…) outside the admin area.
  2. Strong params never permit a *_id key. The controller resolves each id on its own, through a scope, and answers 404 when it isn't found (as for a URL id).
  3. 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.
  4. 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.
  5. Enforced, not remembered:
  6. bin/quality check scoped_ids fails on permit( with a :…_id key and on Model.find(params / Model.find_by(id: params / Model.where(…_id: params in controllers outside app/controllers/admin/.
  7. test/integration/access_matrix_test.rb calls every signed-in route with an id in its path as a stranger, using another user's records, and expects 404. Routes come from config/routes.rb, so new routes are covered; a new kind of record needs one line, or the test fails and says so.
  8. The same test covers the ids sent in forms (Move, boat, upload target).
  9. 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.