Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion domain/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type APIKey struct {
KeyVersion int `bun:"key_version" json:"key_version"`
AccountID string `bun:"account_id" json:"account_id"`
ProjectID string `bun:"project_id" json:"project_id"`
IdentityID string `bun:"identity_id,type:uuid" json:"identity_id"`
IdentityID string `bun:"identity_id,type:uuid,nullzero" json:"identity_id"`
CreatedBy string `bun:"created_by" json:"created_by"`
Scopes []string `bun:"scopes,array" json:"scopes"`
Product string `bun:"product" json:"product"`
Expand Down
5 changes: 5 additions & 0 deletions internal/handler/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ func (a *API) createAPIKeyOp(ctx context.Context, input *CreateAPIKeyInput) (*Cr
if errors.Is(err, service.ErrPolicySubsetViolation) {
return nil, huma.Error400BadRequest(err.Error())
}
// Malformed or dangling reference field (e.g. a nonexistent
// identity_id) is caller error, not a server fault — see #149.
if errors.Is(err, service.ErrInvalidAPIKeyReference) {
return nil, huma.Error400BadRequest("invalid identity_id or credential_policy_id")
}
log.Error().Err(err).Str("name", input.Body.Name).Msg("failed to create API key")
return nil, huma.Error500InternalServerError("failed to create API key")
}
Expand Down
21 changes: 21 additions & 0 deletions internal/service/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"

Expand All @@ -17,6 +19,12 @@ import (
"github.com/highflame-ai/zeroid/internal/store/postgres"
)

// ErrInvalidAPIKeyReference is returned when a caller-supplied reference
// field (e.g. identity_id, credential_policy_id) fails a database-level type
// or foreign-key check that request validation didn't catch — malformed
// input, not a server fault. Callers map this to 400.
var ErrInvalidAPIKeyReference = errors.New("invalid reference on API key")

// APIKeyService handles CRUD operations for API keys (zid_sk_* keys).
type APIKeyService struct {
repo *postgres.APIKeyRepository
Expand Down Expand Up @@ -127,6 +135,13 @@ func (s *APIKeyService) CreateKey(ctx context.Context, req CreateAPIKeyRequest)
if req.IdentityID != "" {
identity, err := s.identitySvc.GetIdentity(ctx, req.IdentityID, req.AccountID, req.ProjectID)
if err != nil {
// A caller-supplied identity_id that's malformed or doesn't
// resolve in this tenant is caller error, not a server fault —
// same class of bug as #149, just triggered by an explicit bad
// value instead of an omitted one.
if errors.Is(err, sql.ErrNoRows) || isInvalidUUIDError(err) {
return nil, fmt.Errorf("%w: identity_id %q: %v", ErrInvalidAPIKeyReference, req.IdentityID, err)
}
return nil, fmt.Errorf("failed to load identity %s for subset check: %w", req.IdentityID, err)
}
if identity.CredentialPolicyID != "" && identity.CredentialPolicyID != policyID {
Expand Down Expand Up @@ -193,6 +208,12 @@ func (s *APIKeyService) CreateKey(ctx context.Context, req CreateAPIKeyRequest)
}

if err := s.repo.Create(ctx, sk); err != nil {
// A malformed UUID reference or a dangling FK is caller error, not a
// server fault — surface it as such instead of leaking a raw
// database error as an opaque 500 (issue #149).
if isInvalidUUIDError(err) || isForeignKeyViolation(err) {
return nil, fmt.Errorf("%w: %v", ErrInvalidAPIKeyReference, err)
}
return nil, fmt.Errorf("failed to store API key: %w", err)
}

Expand Down
17 changes: 17 additions & 0 deletions internal/service/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ func isDuplicateKeyError(err error) bool {
return errors.As(err, &pgErr) && pgErr.Field('C') == "23505"
}

// isInvalidUUIDError returns true if err is a PostgreSQL invalid-text-
// representation error (SQLSTATE 22P02) — e.g. an empty or malformed string
// written to a uuid column. Uses errors.As to handle wrapped errors from
// bun/pgdriver.
func isInvalidUUIDError(err error) bool {
var pgErr pgdriver.Error
return errors.As(err, &pgErr) && pgErr.Field('C') == "22P02"
}

// isForeignKeyViolation returns true if err is a PostgreSQL foreign key
// violation (SQLSTATE 23503) — e.g. a caller-supplied reference ID that
// doesn't exist. Uses errors.As to handle wrapped errors from bun/pgdriver.
func isForeignKeyViolation(err error) bool {
var pgErr pgdriver.Error
return errors.As(err, &pgErr) && pgErr.Field('C') == "23503"
}

// IdentityDeactivatedConflictError is returned by RegisterIdentity when the
// external_id collides with an existing identity that is DEACTIVATED (soft
// deleted). Because deletes are soft, the deactivated row keeps the
Expand Down
48 changes: 48 additions & 0 deletions tests/integration/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,54 @@ func TestCreateAPIKey_CustomCredentialPolicy_Propagates(t *testing.T) {
"custom credential_policy_id supplied at creation must be stored on the key")
}

// TestCreateAPIKey_NoProductNoIdentity_Succeeds is the regression repro for
// #149: POST /api-keys with only the documented-required `name` field (no
// product, no identity_id) previously 500'd — CreateKey left IdentityID as
// "" and the store inserted that empty string into the identity_id uuid
// column, which Postgres rejects (SQLSTATE 22P02). The column is nullable
// (ON DELETE CASCADE / SET NULL FKs across the schema), so the fix persists
// SQL NULL instead of "" when no identity is linked. The key must create
// successfully with an empty identity_id.
func TestCreateAPIKey_NoProductNoIdentity_Succeeds(t *testing.T) {
resp := post(t, adminPath("/api-keys"), map[string]any{
"name": "no-identity-no-product-key",
}, adminHeaders())
require.Equal(t, http.StatusCreated, resp.StatusCode,
"POST /api-keys with only name must succeed, not 500 on the identity_id uuid column")
created := decode(t, resp)
keyID := created["id"].(string)
require.NotEmpty(t, keyID)

fetched := get(t, adminPath("/api-keys/"+keyID), adminHeaders())
require.Equal(t, http.StatusOK, fetched.StatusCode)
got := decode(t, fetched)
assert.Equal(t, "", got["identity_id"], "unlinked key must report an empty identity_id, not error")
}

// TestCreateAPIKey_NonexistentIdentityIDRejected verifies that supplying an
// identity_id that doesn't resolve in the caller's tenant is rejected with
// 400, not the same class of opaque 500 #149 reported for the omitted case.
func TestCreateAPIKey_NonexistentIdentityIDRejected(t *testing.T) {
resp := post(t, adminPath("/api-keys"), map[string]any{
"name": "nonexistent-identity-key",
"identity_id": "00000000-0000-0000-0000-000000000000",
}, adminHeaders())
assert.Equal(t, http.StatusBadRequest, resp.StatusCode,
"a well-formed but nonexistent identity_id must be rejected with 400, not 500")
}

// TestCreateAPIKey_MalformedIdentityIDRejected verifies that a syntactically
// invalid identity_id (not a UUID at all) is rejected with 400 rather than
// leaking the underlying Postgres invalid-input-syntax error as a 500.
func TestCreateAPIKey_MalformedIdentityIDRejected(t *testing.T) {
resp := post(t, adminPath("/api-keys"), map[string]any{
"name": "malformed-identity-key",
"identity_id": "not-a-uuid",
}, adminHeaders())
assert.Equal(t, http.StatusBadRequest, resp.StatusCode,
"a malformed (non-UUID) identity_id must be rejected with 400, not 500")
}

// TestCreateAPIKey_CrossTenantCredentialPolicyRejected verifies the IDOR guard:
// a caller in tenant B cannot associate a new API key with a credential policy
// that belongs to tenant A. GetPolicy is tenant-scoped and returns
Expand Down