fix: create-api-key 500s (uuid 22P02) when product and identity_id both omitted - #253
Open
safayavatsal wants to merge 1 commit into
Open
Conversation
POST /api-keys with only the documented-required `name` field (no product, no identity_id) 500'd: CreateKey left IdentityID as "" when neither was supplied, and the store inserted that empty string into the identity_id uuid column, which Postgres rejects (SQLSTATE 22P02, "invalid input syntax for type uuid"). The column is nullable (REFERENCES identities(id) ON DELETE CASCADE, no NOT NULL) — the schema already supports an unlinked key, the code just wasn't persisting "unlinked" correctly. - domain/apikey.go: IdentityID gets the `nullzero` bun tag, matching the existing pattern already used on ReplacedBy for the same class of optional-UUID-FK field. Empty now persists as SQL NULL instead of "". - internal/service/errors.go: new isInvalidUUIDError (SQLSTATE 22P02) and isForeignKeyViolation (23503) helpers, alongside the existing isDuplicateKeyError (23505). - internal/service/apikey.go: new ErrInvalidAPIKeyReference sentinel. Both CreateKey's repo.Create error path and its identity subset-check lookup (GetIdentity) now classify DB-level bad- reference errors into this sentinel instead of leaking a raw error. The subset-check path was a second, adjacent instance of the same bug class found in the same pass: an explicitly-supplied identity_id that's malformed or doesn't resolve in the tenant also 500'd, for the identical "no errors.Is handling" reason. - internal/handler/apikey.go: maps ErrInvalidAPIKeyReference to 400, alongside the existing credential-policy error mappings. - tests/integration/apikey_test.go: three new tests — the exact no-product/no-identity repro from the issue (now 201, empty identity_id in the response), a well-formed but nonexistent identity_id (400), and a malformed (non-UUID) identity_id (400). Verified the fix is load-bearing, not redundant with the error classification: temporarily reverted just the nullzero tag and reran the primary test — it failed. With the error classification alone in place the failure had already improved from 500 to 400, but the request that should succeed per the documented contract (only `name` is required) still didn't succeed until the nullzero tag was restored. Full build/vet/gofmt clean. Full test suite (unit -race + testcontainers integration) green. Closes highflame-ai#149
Contributor
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #149.
Summary
POST /api-keyswith only the documented-requirednamefield (noproduct, noidentity_id) returned HTTP 500.CreateKeyonly auto-provisions an identity whenProduct != ""; with both empty,IdentityIDstayed"", and the store inserted that empty string into theidentity_iduuidcolumn — Postgres rejects it (SQLSTATE 22P02: invalid input syntax for type uuid). The column is nullable (REFERENCES identities(id) ON DELETE CASCADE, noNOT NULL), so the schema already supports an unlinked key — the code just wasn't persisting "unlinked" correctly.Fix
Per the issue's own preferred option: persist
NULL, not"".domain/apikey.go—IdentityIDgets thenullzerobun tag, matching the identical pattern already used onReplacedByin the same struct for the same class of optional-UUID-FK field. Empty now persists as SQLNULLinstead of"".Defense in depth
While in this code path, also closed the "at minimum, this should not surface as an opaque 500" ask:
internal/service/errors.go— newisInvalidUUIDError(SQLSTATE 22P02) andisForeignKeyViolation(23503) helpers, following the exact convention of the existingisDuplicateKeyError(23505) helper used elsewhere in this package.internal/service/apikey.go— newErrInvalidAPIKeyReferencesentinel. BothCreateKey'srepo.Createerror path and its identity subset-check lookup (GetIdentity) now classify DB-level bad-reference errors into this sentinel instead of leaking a raw error.internal/handler/apikey.go— mapsErrInvalidAPIKeyReferenceto 400, alongside the existing credential-policy error mappings.Adjacent bug found in the same pass: the identity subset-check lookup had the identical failure mode from the other direction — an explicitly-supplied
identity_idthat's malformed or doesn't resolve in the caller's tenant also 500'd, for the same "noerrors.Ishandling" reason. Fixed with the same sentinel, no new abstraction.Tests
tests/integration/apikey_test.go— three new tests:TestCreateAPIKey_NoProductNoIdentity_Succeeds— the exact repro from the issue (now 201, emptyidentity_idin the response).TestCreateAPIKey_NonexistentIdentityIDRejected— a well-formed but nonexistentidentity_id(400).TestCreateAPIKey_MalformedIdentityIDRejected— a malformed (non-UUID)identity_id(400).Verification
Confirmed the
nullzerofix is load-bearing and not redundant with the error-classification hardening: temporarily reverted just the tag and reran the primary test — it failed. With the error classification alone in place, the failure had already improved from 500 → 400, but the request that should succeed per the documented contract (onlynameis required) still didn't succeed until the tag was restored. Both pieces are independently necessary.GOEXPERIMENT=jsonv2 go build ./...cleanGOEXPERIMENT=jsonv2 go vet ./...cleangofmt -l .clean-race, fulltests/integration(testcontainers) suite including the 3 new tests and the existing api-key regression tests