invoke-ai / invoke-ai/InvokeAI
Per-image mutation routes authorize and write in separate statements, so a revoked permission still applies
- Dominant language
- Python
- Stars
- 28.2k
- Forks
- 3k
- Avg merge
- 6d 5h
- Merged PRs (30d)
- 19
Description
Every per-image mutation route authorizes and then writes as two separate statements, so a permission that is revoked in between is applied anyway:
```python
_assert_image_owner(image_name, current_user) # decision
ApiDependencies.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:
```sql
DELETE FROM 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;
- storage errors keep propagating rather than being read as "denied" (the invariant #9394 established).
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.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.