ruby / authz

Two layers protect every mutation:

  1. authorize the actor (logged in, role allows the action)
  2. authorize the row (the actor owns the data being touched)

The first layer lives in the web framework handler hierarchy. The second layer lives inside the SQL.

Gate mutations in the UPDATE

Pass the user id into the UPDATE clause. A row not owned by the actor returns zero affected rows, no exception:

rows = db.exec(<<~SQL, [body, note_id, current_user["id"]])
  UPDATE
    notes
  SET
    body = $1
  WHERE
    id = $2
    AND user_id = $3
  RETURNING
    id
SQL
if rows == []
  return head 404
end

Authorization in the application layer (load row, compare row.user_id, then update) leaves a window where the row could be modified between read and write. Folding the check into the write closes that window and is one round trip.

Scope reads the same way

db.exec(<<~SQL, [list_id, current_user["id"]])
  SELECT
    id,
    name
  FROM
    lists
  WHERE
    id = $1
    AND user_id = $2
SQL

A 404 on a row the user does not own keeps existence private. A 403 leaks "this id exists, but it isn't yours."

Cross-user IDOR tests

Every mutation handler gets a test that signs in as user A and attempts to act on user B's row:

class NotesUpdateTest < HandlerTest
  def test_other_users_note_returns_404
    a = insert_user
    b = insert_user
    note = insert_note(user_id: b["id"], body: "secret")

    sign_in_as(a)
    resp = post("/notes/update", id: note["id"], body: "tampered")

    ok { resp.status == 404 }

    row = db.exec(<<~SQL, [note["id"]]).first
      SELECT
        body
      FROM
        notes
      WHERE
        id = $1
    SQL
    ok { row["body"] == "secret" }
  end
end

A ping test fails CI if any mutation handler is missing its cross-user case. The test is a few lines; the class of bug it prevents is the most common authorization mistake in a CRUD app.

Trust the session, not the params

Identity in the URL or form is a hint, not a fact. The handler ignores any actor_id field from the request and always reads current_user["id"] from the session:

class Connections::Add < Framework::Handler
  def handle
    target_id = params["target_id"].to_i
    if target_id <= 0 || target_id == current_user["id"]
      return head 400
    end

    Connections::Create.new(db).call(
      actor_id: current_user["id"],
      target_id: target_id
    )
    head 200
  end
end

The same rule applies to audit logs: the recorded actor is always the session user, never a value the client supplied.

Allowlists for sort and filter

Sort columns and directions go through a fixed allowlist before they touch SQL. See ruby / params.

← All articles