Skip to content

Per-image mutation routes authorize and write in separate statements, so a revoked permission still applies #9534

Description

@lstein

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)          # 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:

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_imagesboards.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 fix(api): report partial failures and bound batch bodies on image routes #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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions