You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every per-image mutation route authorizes and then writes as two separate statements, so a permission that is revoked in between is applied anyway:
_assert_image_owner(image_name, current_user) # decisionApiDependencies.invoker.services.images.update(...) # write, under a decision that may be stale
The window is small but it is a real check-then-act: between the two statements a board can flip from Public to Private, an image can be reassigned to a board the caller cannot write to, or the caller's access to the board can be withdrawn. The write still lands.
What is already conditional
#9394 closed the one instance where the race caused incorrect data movement rather than merely a late-by-microseconds mutation. board_image_records.remove_image_from_board scopes its DELETE to the board the caller was authorized against:
DELETEFROM board_images WHERE image_name = ? AND board_id = ?;
An unscoped delete followed the image if it moved between the authorization and the write, applying a decision taken about one board to a different one. The scoped form matches zero rows instead, and the route classifies the zero-row outcome (moved / already uncategorized / deleted) rather than reporting a success that did not happen.
The remaining routes — star, unstar, add-to-board — have no equivalent. Their worst case is a mutation applied under a decision that was true a moment earlier, which is why this is a follow-up and not a blocker on that PR.
Why the obvious fix does not fit
Encoding the authorization as a WHERE clause means encoding all of it, and it is a four-way disjunction spanning three tables (invokeai/app/api/routers/_access.py):
the caller is an admin (users.is_admin), or
the caller owns the image (images.user_id), or
the caller owns the board the image sits on (board_images → boards.user_id), or
that board is Public (boards.board_visibility).
Writing that as a correlated subquery on every mutating statement duplicates the policy in SQL, in several places, with no mechanism keeping the copies in step with the Python one. The first divergence is a silent authorization bug.
The shape that would work
Move the decision into the service call, so authorization and mutation share one transaction and one policy implementation:
a single authorize_image_mutation(image_name, user) used by the service layer inside the transaction that performs the write, rather than by each route beforehand;
routes keep reporting per-name outcomes exactly as they do now (failed_images for a genuine failure, silent skip for a name the caller may not touch), so the API contract does not change;
This is the same refactor already wanted for the last-admin guards, which have the identical check-then-act shape in UserService. Worth doing once, for both.
Not urgent because
The mutations at risk (star, unstar, board add) are low-impact and visible to the user who performed them.
The high-impact case — a delete or a board move landing on the wrong board — is already conditional.
Any fix touches the service interfaces for images, boards and users together, which is a poor fit for a bug-fix PR.
Every per-image mutation route authorizes and then writes as two separate statements, so a permission that is revoked in between is applied anyway:
The window is small but it is a real check-then-act: between the two statements a board can flip from Public to Private, an image can be reassigned to a board the caller cannot write to, or the caller's access to the board can be withdrawn. The write still lands.
What is already conditional
#9394 closed the one instance where the race caused incorrect data movement rather than merely a late-by-microseconds mutation.
board_image_records.remove_image_from_boardscopes its DELETE to the board the caller was authorized against:An unscoped delete followed the image if it moved between the authorization and the write, applying a decision taken about one board to a different one. The scoped form matches zero rows instead, and the route classifies the zero-row outcome (moved / already uncategorized / deleted) rather than reporting a success that did not happen.
The remaining routes — star, unstar, add-to-board — have no equivalent. Their worst case is a mutation applied under a decision that was true a moment earlier, which is why this is a follow-up and not a blocker on that PR.
Why the obvious fix does not fit
Encoding the authorization as a WHERE clause means encoding all of it, and it is a four-way disjunction spanning three tables (
invokeai/app/api/routers/_access.py):users.is_admin), orimages.user_id), orboard_images→boards.user_id), orboards.board_visibility).Writing that as a correlated subquery on every mutating statement duplicates the policy in SQL, in several places, with no mechanism keeping the copies in step with the Python one. The first divergence is a silent authorization bug.
The shape that would work
Move the decision into the service call, so authorization and mutation share one transaction and one policy implementation:
authorize_image_mutation(image_name, user)used by the service layer inside the transaction that performs the write, rather than by each route beforehand;failed_imagesfor a genuine failure, silent skip for a name the caller may not touch), so the API contract does not change;This is the same refactor already wanted for the last-admin guards, which have the identical check-then-act shape in
UserService. Worth doing once, for both.Not urgent because