feat: add API Key authentication for GTFS-RT feed consumers#68
Conversation
aaronbrethorst
left a comment
There was a problem hiding this comment.
Amelia, this is a well-scoped feature — the SHA-256 key hashing, the APIKeyStore interface for testability, and the thorough test coverage across both the middleware and the store layer are all well done. The seed data with a pre-hashed dev key is a nice touch for local testing. A few things need attention before this can merge.
Critical
- Needs rebase on latest
main— the branch is missing000004_add_vehicle_received_indexwhich has since landed. Your migration number (000005) is correct and won't need renumbering, but the branch needs to include 000004 forgolang-migrateto run them in sequence.
Important
-
UpdateAPIKeyLastUsedfailure blocks feed access (api_key_auth.go:51-54). If thelast_used_atupdate fails (transient DB error, connection hiccup), the middleware returns 500 and the feed consumer gets nothing — even though their API key was already validated. Thelast_used_atupdate is an audit convenience, not a security requirement. On a hot path like the GTFS-RT feed, this should not block the response. Log the error and proceed:if err := store.UpdateAPIKeyLastUsed(r.Context(), apiKey.ID); err != nil { slog.Error("failed to update api key last_used_at", "api_key_id", apiKey.ID, "error", err) }
-
Missing
sloglogging on 500 responses (api_key_auth.go:42,api_key_auth.go:52). WhenGetAPIKeyByHashreturns a non-pgx error, the handler returns 500 but doesn't log the error. The established pattern in this codebase (seehandlers.go,auth.go) is to callslog.Error(...)before returning a 500 so operators can diagnose failures. Please add structured logging to both 500 paths. -
docker-compose.ymluses${JWT_SECRET}from host environment (docker-compose.yml:26). The previous docker-compose had noJWT_SECRETset at all, and the app would fail on startup. Your fix is reasonable, but using${JWT_SECRET}requires developers to set the variable in their host environment or a.envfile, which is a friction change for anyone runningdocker compose upwithout extra setup. Consider using a hardcoded dev value (like other PRs have done) to keep local dev zero-config:JWT_SECRET: "this-is-a-local-dev-secret-1234567890"
Suggestions
-
Missing trailing newlines in
db/query.sql,migrations/000005_add_api_keys.down.sql,migrations/000005_add_api_keys.up.sql, andseed_dev.sql. Most editors and POSIX tools expect a trailing newline. Minor, but easy to fix while you're in there. -
The
updated_attrigger (migrations/000005_add_api_keys.up.sql:12-23) is a valid approach, but the rest of the codebase handlesupdated_atat the application/SQL layer (SET updated_at = NOW()in queries). The trigger works fine, but the inconsistency could confuse future contributors who expect one pattern and find another. Not a blocker — just something to be aware of.
Strengths
- SHA-256 key hashing: Raw keys are never stored, which is the right approach for API key security.
- Complete test coverage: Seven middleware tests covering every branch (missing header, invalid key, inactive key, store failure, update failure, valid key) plus three integration tests on the store layer.
- Clean interface design:
APIKeyStoreis minimal and follows the project's established pattern of handler-scoped interfaces. - Dev seed data: The pre-hashed
dev-feed-keyinseed_dev.sqlwith the raw key documented in a comment makes local testing frictionless.
Recommended Action
Request changes. Rebase on latest main, make the UpdateAPIKeyLastUsed failure non-blocking, and add slog logging to the 500 paths.
6c411eb to
567bcdf
Compare
|
Hi Aaron, thanks for your review! All issues mentioned are addressed:
Thank you, please let me know if these work! |
aaronbrethorst
left a comment
There was a problem hiding this comment.
Amelia, thank you for the quick turnaround — you cleanly addressed every item from the last round. I verified each one:
- ✅ Rebased / migration renumbered — migration is now
000007, and the branch picked up000004/000005. - ✅
UpdateAPIKeyLastUsedis now non-blocking — it logs and proceeds (api_key_auth.go:53-55), with a test that locks the behavior in. - ✅
slog.Erroron both 500 paths —api_key_auth.go:43is covered. - ✅
JWT_SECREThardcoded dev value — exactly as suggested (docker-compose.yml:26). - ✅ Trailing newlines — all four touched SQL files now end with a newline.
The auth design itself is genuinely solid: SHA-256 hex hashing with a UNIQUE indexed key_hash, the errors.Is(err, pgx.ErrNoRows) 401-vs-500 split correctly surviving the %w wrap in the store, and the deliberate non-blocking timestamp update are all done right. Unfortunately one thing has slipped since you last pushed, and it's a hard blocker.
Critical Issues (1 found)
-
The branch no longer merges — it conflicts with
main. GitHub reportsmergeable: CONFLICTING/mergeStateStatus: DIRTY.mainhas moved well past your branch point (PRs #55, #60, #63 and others landed since), and a test merge produces conflicts in three files:db/query.sqldb/query.sql.gomain.go
Your
main.gochange also re-adds and reorders the/api/v1/admin/usersroutes, which now collide with the user-management and user-vehicle work already onmain. Additionally,mainnow contains000006_add_tripsand000008_add_user_vehicles, neither of which is on this branch — so the rebase you did earlier is stale. Please rebase on the currentmainagain, resolve the three conflicts (keeping yourrequireAPIKeywiring on the feed route and dropping the now-redundant admin-users reshuffle), and re-runcd db && sqlc generatesodb/query.sql.gois regenerated rather than hand-merged.
Important Issues (3 found)
-
The feed endpoint became authenticated, but the docs still present it as open (
README.md:87,README.md:95). This is a breaking change for every existing consumer:GET /gtfs-rt/vehicle-positionsnow returns 401 without anX-API-Keyheader. The README table makes no mention of the requirement. Please document the header and add acurlexample (the dev keydev-feed-keyis right there inseed_dev.sql). -
Nothing tests that the feed route is actually protected (
main.go:75). The existing feed tests callhandleGetFeed(tracker)directly, bypassing the middleware entirely. If a future refactor drops thefeedAuth(...)wrapper, the feed silently becomes public again and every test still passes. Add a test that exercises the wired route (orrequireAPIKey(...)-wrapped handler) and asserts an unauthenticated request gets 401. For a security control, the wiring is the thing most worth pinning. -
The store's not-found error contract is untested (
store.go:159-162,store_api_keys_test.go). The middleware's 401-vs-500 decision depends onGetAPIKeyByHashreturning an error for whicherrors.Is(err, pgx.ErrNoRows)is true through thefmt.Errorf("...: %w", err)wrap. The middleware test injects a barepgx.ErrNoRowsvia the mock, so it never exercises the real wrapped error. Add a store integration test: delete all rows, look up a missing hash, andrequire.True(t, errors.Is(err, pgx.ErrNoRows)). If someone ever swaps%wfor%v, unknown keys would start returning 500 and no test would catch it.
Suggestions (5 found)
-
Downgrade the
last_used_atfailure log fromErrortoWarn(api_key_auth.go:54). You deliberately tolerate this failure, so logging it at the same level as the genuinely-actionable 500 (api_key_auth.go:43) trains operators to ignoreError. A transient DB hiccup or a client disconnect (r.Context()cancellation) would also flood the error stream.slog.WarnkeepsErrormeaningful. -
last_used_atis written on every feed read. The feed is the highest-traffic, short-poll endpoint, so each poll becomes a write (plus theupdated_attrigger fires). It's correctly non-fatal, but consider throttling — only update whenlast_used_atis older than, say, a few minutes — to avoid write amplification under load. Fine to defer, but worth a follow-up. -
Non-idiomatic and misleading comments in the tests:
store_api_keys_test.go:11-13uses a Javadoc-style/** ... */block; Go convention is//lines, and as written it's attached to the first test rather than the file. Convert to//and leave a blank line before the function.api_key_auth_test.go:71— the doc comment describes all six scenarios but sits onTestRequireAPIKey_MissingHeader. Scope it to that one function.api_key_auth_test.go:81,96,127,170restate the test name/assertion; either drop them or promote to proper per-function doc comments. (Your comment at:152explaining the non-blocking intent is the model to follow — that one's great.)
-
CreateAPIKeyhas no production caller (store.go,db/query.sql). Keys can currently only be minted via raw SQL/seed. Fine for this milestone, but worth a follow-up ticket for an admin endpoint to create/rotate keys. -
A couple of low-cost test additions: assert the
updated_attrigger actually advancesupdated_aton update (store_api_keys_test.go:38only checkslast_used_at), and add a duplicate-hash insert test to exercise theUNIQUEconstraint onkey_hash.
Strengths
- Correct, defensible auth design — raw keys never stored, hashed lookup on a
UNIQUEindexed column, no secret-dependent branching to worry about timing-wise. - The
%werror chain is right —errors.Is(err, pgx.ErrNoRows)survives the store's wrapping, which is the easy thing to get wrong here. - Thoughtful non-blocking design —
UpdateAPIKeyLastUsedfailure logs and proceeds, withTestRequireAPIKey_UpdateLastUsedFailure_DoesNotBlockRequestand the inactive-key test asserting the update is skipped on the reject path. - Strong branch coverage on the middleware (missing/invalid/inactive/store-failure/update-failure/valid) plus store round-trip integration tests.
- You implemented all five pieces of prior feedback exactly as requested, including the
JWT_SECRETdev default and the non-blocking refactor.
Recommended Action
Request changes.
- Rebase on current
mainand resolve the three conflicts; regeneratedb/query.sql.gowithsqlc. - Document the new
X-API-Keyrequirement in the README. - Add the route-protection test and the store not-found
errors.Istest. - Consider the suggestions (log level, comment cleanup) while you're in there.
- Re-request review after the rebase — once it merges cleanly and the two tests are in, this is ready.
Summary
This PR adds API key authentication for feed consumers on
GET /gtfs-rt/vehicle-positions.Previously, the GTFS-Realtime vehicle positions feed could be accessed without any feed-specific authentication. As requested in Milestone 2, we need an authentication for accessing the feed.
The implementation hashes raw API keys with SHA-256 before lookup, rejects missing/invalid/inactive keys, and updates
last_used_aton successful requests.Changes
APIKeymodel to represent rows in theapi_keystable.requireAPIKeymiddleware for feed access.hashAPIKey(raw string) string, which computes the SHA-256 hash of the provided raw key and returns the hex-encoded string.api_keystable and a trigger/function pair to automatically maintainupdated_aton updates.seed_dev.sqlto include a development API key entry for local manual testing,Testing
Test file added:
TestHashAPIKeyTestRequireAPIKey_MissingHeaderTestRequireAPIKey_InvalidKeyTestRequireAPIKey_InactiveKeyTestRequireAPIKey_StoreFailureTestRequireAPIKey_UpdateLastUsedFailureTestRequireAPIKey_ValidKeyTestStore_CreateAndGetAPIKeyByHashTestStore_UpdateAPIKeyLastUsedTestStore_GetAPIKeyByHash_InactiveRunning
go test ./...: All tests passedManual Testing with
curl:seed_dev.sql:curl -i -H "X-API-Key: dev-feed-key" http://localhost:8080/gtfs-rt/vehicle-positions": return status 200 OKcurl -i -H "X-API-Key: dev-feed-key-1234567890abcdef" http://localhost:8080/gtfs-rt/vehicle-positions: return 401 unauthorized