Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5141eaa
fix(api): report partial failures and bound batch bodies on image routes
lstein Jul 28, 2026
b8823e1
chore: regenerate openapi.json for the new failed_images fields and b…
lstein Jul 28, 2026
621a8bc
Merge branch 'main' into fix/images-batch-partial-failures-and-bounds
JPPhoto Aug 9, 2026
b1659e8
Merge branch 'main' into fix/images-batch-partial-failures-and-bounds
JPPhoto Aug 9, 2026
32f4c2d
fix(api): bound the /images/download name list too
lstein Aug 17, 2026
3ddb72a
fix(api,ui): bound every image_names batch body, chunk oversized ones…
lstein Aug 17, 2026
18ef116
fix(api): board batch moves skip foreign names instead of aborting
lstein Aug 17, 2026
c3d2ad4
fix(api): treat a mid-batch not-found as a skip, not a storage failure
lstein Aug 19, 2026
11021a9
fix(ui): don't report a partially-scheduled bulk download as failed
lstein Aug 19, 2026
93420df
Merge remote-tracking branch 'origin/main' into fix/images-batch-part…
lstein Aug 19, 2026
d876b3e
fix(api): decide board write access per name, and report what it cann…
lstein Aug 20, 2026
c59e02b
fix(ui): keep a chunked batch inside the session that started it
lstein Aug 20, 2026
c267fae
fix(ui): wait for the board move before clearing the change-board sel…
lstein Aug 20, 2026
46c47b2
fix(ui): guard async change-board results
JPPhoto Aug 20, 2026
55b370e
fix(ui): report a failed video move regardless of who owns the modal
lstein Aug 21, 2026
5dcdcdd
fix(ui): close the holes an adversarial pass found in the new guards
lstein Aug 21, 2026
806f985
fix(images): prevent stale batch results across sessions
JPPhoto Aug 21, 2026
135191a
test(images): ignore unordered delete response
JPPhoto Aug 21, 2026
ed50cd3
test(ui): bound the reopen guards, and correct two stale comments
lstein Aug 21, 2026
dbb5297
fix(api): keep an expired session's 401 a failure, not an abort
lstein Aug 21, 2026
3242cdc
fix(api): triage a failed session check into expiry vs takeover
lstein Aug 21, 2026
6fe2c2d
fix(ui): silence downloads on expiry too, and pin the untested triage…
lstein Aug 22, 2026
eb89b1c
fix(ui): clear the workspace slices and their undo stacks on account …
lstein Aug 22, 2026
acc6982
fix(ui): purge upscale too, spare the mode switch, and read anyOf bounds
lstein Aug 22, 2026
6dd6343
Merge remote-tracking branch 'origin/main' into fix/images-batch-part…
lstein Aug 23, 2026
e81b402
fix(api,ui): classify the zero-row remove, fail a revoked destination…
lstein Aug 23, 2026
41ed97c
fix(api,ui): repair the interruption toast's key, cover the everyday …
lstein Aug 23, 2026
1defaf5
fix(api,ui): classify the single remove's zero-row race, reconcile lo…
lstein Aug 23, 2026
a18445b
fix(ui): close the self-review's two residual gaps in the lost-chunk …
lstein Aug 23, 2026
8a508f7
fix(api,ui): abort errored chunks under takeover, and stop laundering…
lstein Aug 23, 2026
4c56cd0
test(api): pin the narrowed catch from the not-found side too
lstein Aug 23, 2026
4ae99b3
fix(ui): stop a stale 401 from ending the session that replaced it
lstein Aug 24, 2026
64fdf29
fix(ui): close the paths the self-review found around the new 401 guard
lstein Aug 24, 2026
50bf9c3
fix(api,ui): let a client trust the 403 it drops an image reference on
lstein Aug 24, 2026
e97dc0b
fix(api,ui): key the identity query by its token, and give videos the…
lstein Aug 24, 2026
142eb44
Merge branch 'main' into fix/images-batch-partial-failures-and-bounds
JPPhoto Aug 24, 2026
0dd3b6d
fix(api,ui): answer gone and denied differently, instead of guessing …
lstein Aug 24, 2026
b03de59
fix(api): stop any failure from wearing the deleted-image answer
lstein Aug 24, 2026
75d8c87
fix(api,ui): close what the self-review found around the gone/denied …
lstein Aug 25, 2026
b475478
Merge branch 'main' into fix/images-batch-partial-failures-and-bounds
JPPhoto Aug 25, 2026
db84e85
Merge branch 'main' into fix/images-batch-partial-failures-and-bounds
JPPhoto Aug 25, 2026
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
59 changes: 52 additions & 7 deletions invokeai/app/api/routers/_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.board_records.board_records_common import BoardVisibility
from invokeai.app.services.board_records.board_records_common import (
BoardRecordNotFoundException,
BoardVisibility,
)


def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
Expand All @@ -28,18 +31,56 @@ def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> N

board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
if board_id is not None:
# The board *record*, not its DTO: the decision needs only the owner and the
# visibility, and the DTO would drag in cover-image resolution plus three COUNT
# aggregates — five extra queries and five extra ways to fail per name.
#
# Only a board positively known to be gone falls through to the 403. A storage error
# propagates instead of being caught here: `board_records.get` deliberately does not
# translate sqlite errors into not-found, and a caller that cannot decide ownership
# must not report the name as an ordinary permission denial — the batch loops treat a
# 403 as a silent auth skip, which turned a locked database into images dropped from
# the response with no failure reported at all.
try:
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
board = ApiDependencies.invoker.services.board_records.get(board_id)
except BoardRecordNotFoundException:
pass
else:
if board.user_id == current_user.user_id:
return
if board.board_visibility == BoardVisibility.Public:
return
except Exception:
pass

raise HTTPException(status_code=403, detail="Not authorized to modify this image")


def _assert_image_record_exists(image_name: str) -> None:
"""Turn a refusal into a 404 when the image is positively gone.

The two refusals mean opposite things to a client holding a reference to the image — a
workflow's image field, a reference image on a canvas layer. Gone is permanent, and the
reference should be dropped. Denied is a permission decision that can be reversed (a board
flipped back to Shared, an owner re-granting access), and dropping the reference over one
destroys work the user cannot get back by restoring the permission.

Nothing above can tell them apart: the ownership test rests on `images.user_id`, which is
gone with the row, so a deleted image reaches that same 403 as a foreign one. So the
distinction is made here, on the refusal path only — the happy path pays nothing for it.

A storage error propagates rather than answering either, so an unreadable database cannot
present as a deleted image and take the user's references down with it. `exists` is a bare
row probe rather than `get` for the same reason from the other side: `get` deserializes, so
a row written by a newer version — an enum value this one does not know — would fail exactly
as absence does, and a live image would be reported gone.

The cost is that an authenticated caller can now tell an absent image from one they may not
read. Image names are generated UUIDs, so this buys an attacker nothing they could enumerate,
and it is the answer admins have always received.
"""
if not ApiDependencies.invoker.services.image_records.exists(image_name):
raise HTTPException(status_code=404, detail="Image not found")


def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None:
"""Raise 403 if the current user may not view the image.

Expand All @@ -57,13 +98,17 @@ def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault

board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
if board_id is not None:
# See `assert_image_owner` for why this reads the board record and catches only
# not-found: a lookup that cannot be decided must not present as a permission decision.
try:
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
board = ApiDependencies.invoker.services.board_records.get(board_id)
except BoardRecordNotFoundException:
pass
else:
if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
return
except Exception:
pass

_assert_image_record_exists(image_name)
raise HTTPException(status_code=403, detail="Not authorized to access this image")


Expand Down
Loading
Loading