ruby / authz
Two layers protect every mutation:
- authorize the actor (logged in, role allows the action)
- 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.