Skip to content

fix(api): a malformed id is a 422 or 400, not a server error - #546

Open
jwvanderstam wants to merge 3 commits into
mainfrom
fix/malformed-id-not-500-v2
Open

jwvanderstam wants to merge 3 commits into
mainfrom
fix/malformed-id-not-500-v2

Conversation

@jwvanderstam

Copy link
Copy Markdown
Owner

What was wrong

On main, about thirty routes answered 500 to a path id that is not a UUID: users, workspaces, conversations, connectors, memories, annotations and API keys. The id was typed str, Postgres refused it (InvalidTextRepresentation), and the route's own except Exception turned that into a 500. A malformed X-Workspace-ID header did the same inside the workspace guard.

The fix

  • UuidId (src/models.py): an Annotated[str, AfterValidator] on every UUID path parameter. A malformed id is a 422 before the route runs, as an int id already was. A well-formed unknown id is still a 404.
  • check_workspace_access refuses a header or query workspace that is not a UUID with a 400, before any database lookup. The change only refuses more requests; it admits nothing new.
  • A global exception handler was tried first and does not work: the routes catch the error themselves.

Tests

  • tests/integration/test_malformed_ids_are_not_server_errors.py walks every object route from app.openapi() with a malformed id and expects no 5xx. It fails on main and passes here; together with the HTTP authorisation matrix, 123 passed.
  • Unit tests that sent ids like ws-1 or some-id now use fixed UUIDs (41 tests in 10 files). The ten files pass locally: 232 passed, 1 skipped.
  • test_users_me_identity: members.for_user is no longer a bare mock. A bare mock is truthy, so it became the pinned workspace, and the second guard call refused it. This is the trap testing.md describes.
  • ruff, mypy, bandit and lint-imports are clean. The full fast suite was not run locally, for memory; CI runs it.

Review

This PR touches src/security_fastapi.py, a security-critical path, so it needs the QP-9 separate-session review before merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KkWPq9YWxtZ6hbDvMft8fh

A path id addressing a UUID-keyed row was typed `str`, so a value that is
not a UUID reached Postgres, which refused it; each route's broad `except`
turned that into a 500. About thirty routes did this, across users,
workspaces, conversations, connectors, memories, annotations and keys. A
malformed `X-Workspace-ID` did the same inside the workspace guard.

- `UuidId` in `src/models.py` validates those path ids at the boundary: a
  malformed one is a 422 before the route runs, as an `int` id already was;
  a well-formed unknown one is still a 404.
- `check_workspace_access` refuses a header or query workspace that is not
  a UUID with a 400, before any lookup. It only refuses more.
- `tests/integration/test_malformed_ids_are_not_server_errors.py` walks
  every object route over HTTP against Postgres with a malformed id.
- Unit tests that addressed routes with ids like `ws-1` now use UUIDs, and
  `/users/me`'s fixture no longer lets a bare mock become the pinned
  workspace.

Touches `src/security_fastapi.py`: needs the QP-9 separate-session review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPq9YWxtZ6hbDvMft8fh
Copilot AI balanced review requested due to automatic review settings November 14, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

jwvanderstam and others added 2 commits November 15, 2026 17:16
Review findings on #546:

- The guard checked only a workspace it resolved itself from the header,
  so a route handing it a value directly still answered 500 to a malformed
  one: `GET /api/workspaces/active` (the header) and `POST
  /api/workspaces/switch` (the body). It now checks whatever names the
  workspace.
- `uuid.UUID` accepts `urn:uuid:` forms, which Postgres refuses, and the
  value reaches Postgres as written. Only the canonical form passes now,
  in either case.
- Three unit test files still addressed routes with ids like `some-id`
  (`test_auth_routes_unit`, `test_cw2_soft_delete`,
  `test_cw2cdef_soft_delete`); two more passed `ws-1` to the guard. They
  use UUIDs now.

The integration test probes both routes and the urn form, beside a
control that the caller's own workspace is admitted on each; five of its
probes fail on the previous guard. Unit tests cover the guard directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPq9YWxtZ6hbDvMft8fh
`test_memory_routes_full` got a 422 for `abc-123` and `ghost`. And
`test_workspace_role_enforcement`'s export check asserted only `!= 403`,
which a 422 satisfies: it still passed while testing nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPq9YWxtZ6hbDvMft8fh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants