Skip to content

fix: create-api-key 500s (uuid 22P02) when product and identity_id both omitted #149

@saucam

Description

@saucam

Summary

POST /api/v1/api-keys returns HTTP 500 "failed to create API key" when the request body omits both product and identity_id. The underlying error is a Postgres type failure — an empty string is written into the uuid column api_keys.identity_id:

failed to store API key: failed to create API key:
ERROR: invalid input syntax for type uuid: "" (SQLSTATE=22P02)

This is the documented minimal request shape (only name is required:"true" in CreateAPIKeyInput), so the simplest valid-looking call fails with an opaque 500.

Reproduction

curl -s -X POST http://localhost:8899/api/v1/api-keys \
  -H 'Content-Type: application/json' \
  -H 'X-Account-ID: acme' -H 'X-Project-ID: prod' -H 'X-User-ID: admin' \
  -d '{"name":"my-service-key"}'

Response:

{ "title": "Internal Server Error", "status": 500, "detail": "failed to create API key" }

Server log:

{"level":"error","error":"failed to store API key: failed to create API key: ERROR: invalid input syntax for type uuid: \"\" (SQLSTATE=22P02)","caller":".../internal/handler/apikey.go:147","message":"failed to create API key"}

Workaround: include a product (e.g. "product":"my-svc") so a service identity is auto-provisioned, or pass an explicit identity_id.

Root cause

internal/service/apikey.goCreateKey:

  • An identity is auto-provisioned only when req.IdentityID == "" && req.Product != "" (line ~79). With both empty, req.IdentityID stays "".
  • The key struct is built with IdentityID: req.IdentityID (line ~184). domain.APIKey.IdentityID is tagged bun:"identity_id,type:uuid" (domain/apikey.go:36), and the column is identity_id UUID REFERENCES identities(id) ON DELETE SET NULLnullable (migrations/001_init_schema.up.sql:96).
  • s.repo.Create then inserts "" into a uuid column → SQLSTATE 22P02.
  • The handler maps any create error to a generic huma.Error500InternalServerError (internal/handler/apikey.go:147).

The doc comment on CreateKey states "Every key is linked to an identity and assigned a credential policy", but that invariant is enforced only on the Product != "" path. The no-product / no-identity path violates it and fails at the DB layer instead of being handled.

Expected behavior

One of (in rough order of preference):

  1. Persist NULL, not "" — the column is nullable (ON DELETE SET NULL), so an unlinked key should store SQL NULL for identity_id. Likely needs IdentityID to be *string/sql.NullString (or a nullzero bun tag) so empty maps to NULL.
  2. Auto-provision in the no-product path too, if the "every key has an identity" invariant is meant to be hard — derive a default/service identity even when product is unset.
  3. Fail with 400 and a clear message if identity_id/product is genuinely required, rather than a 500 from a DB type error.

At minimum, this should not surface as an opaque HTTP 500.

Environment

  • zeroid main (current HEAD), standalone docker-compose, Postgres 17.
  • Reproduced against /api/v1/api-keys with default tenant headers; no auth/network specifics involved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions