Skip to content

Commit 33ec16d

Browse files
lsteinCopilotclaudeJPPhoto
authored
Feature: Shared/private workflows and image boards in multiuser mode (#9018)
* feat: Per-user workflow libraries in multiuser mode (#114) * Add per-user workflow isolation: migration 28, service updates, router ownership checks, is_public endpoint, schema regeneration, frontend UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * feat: add shared workflow checkbox to Details panel, auto-tag, gate edit/delete, fix tests Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Restrict model sync to admin users only (#118) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * feat: distinct splash screens for admin/non-admin users in multiuser mode (#116) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Disable Save when editing another user's shared workflow in multiuser mode (#120) * Disable Save when editing another user's shared workflow in multiuser mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(app): ruff * Add board visibility (private/shared/public) feature with tests and UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Enforce read-only access for non-owners of shared/public boards in UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix remaining board access enforcement: invoke icon, drag-out, change-board filter, archive Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix: allow drag from shared boards to non-board targets (viewer, ref image, etc.) Previously, images in shared boards owned by another user could not be dragged at all — the draggable setup was completely skipped in GalleryImage.tsx when canWriteImages was false. This blocked ALL drop targets including the viewer, reference image pane, and canvas. Now images are always draggable. The board-move restriction is enforced in the dnd target isValid functions instead: - addImageToBoardDndTarget: rejects moves from shared boards the user doesn't own (unless admin or board is public) - removeImageFromBoardDndTarget: same check Other drop targets (viewer, reference images, canvas, comparison, etc.) remain fully functional for shared board images. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): add auth requirement to all sensitive routes in multimodal mode * chore(backend): ruff * fix (backend): improve user isolation for session queue and recall parameters - Sanitize session queue information of all cross-user fields except for the timestamps and status. - Recall parameters are now user-scoped. - Queue status endpoints now report user-scoped activity rather than global activity - Tests added: TestSessionQueueSanitization (4 tests): 1. test_owner_sees_all_fields - Owner sees complete queue item data 2. test_admin_sees_all_fields - Admin sees complete queue item data 3. test_non_owner_sees_only_status_timestamps_errors - Non-owner sees only item_id, queue_id, status, and timestamps; everything else is redacted 4. test_sanitization_does_not_mutate_original - Sanitization doesn't modify the original object TestRecallParametersIsolation (2 tests): 5. test_user1_write_does_not_leak_to_user2 - User1's recall params are not visible in user2's client state 6. test_two_users_independent_state - Both users can write recall params independently without overwriting each other fix(backend): queue status endpoints report user-scoped stats rather than global stats * fix(workflow): do not filter default workflows in multiuser mode Problem: When categories=['user', 'default'] (or no category filter) and user_id was set for multiuser scoping, the SQL query became WHERE category IN ('user', 'default') AND user_id = ?, which excluded default workflows (owned by "system"). Fix: Changed user_id = ? to (user_id = ? OR category = 'default') in all 6 occurrences across workflow_records_sqlite.py — in get_many, counts_by_category, counts_by_tag, and get_all_tags. Default workflows are now always visible regardless of user scoping. Tests added (2): - test_default_workflows_visible_when_listing_user_and_default — categories=['user','default'] includes both - test_default_workflows_visible_when_no_category_filter — no filter still shows defaults * fix(multiuser): scope queue/recall/intermediates endpoints to current user Several read-only and event-emitting endpoints were leaking aggregate cross-user activity in multiuser mode: - recall_parameters_updated event was broadcast to every queue subscriber. Added user_id to the event and routed it to the owner + admin rooms only. - get_queue_status, get_batch_status, counts_by_destination and get_intermediates_count now scope counts to the calling user (admins still see global state). Removed the now-redundant user_pending/user_in_progress fields and simplified QueueCountBadge. - get_queue_status hides current item_id/session_id/batch_id when the current item belongs to another user. Also fixes test_session_queue_sanitization assertions that lagged behind the recently expanded redaction set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(backend): ruff * fix(multiuser): reject anonymous websockets and scope queue item events Close three cross-user leaks in the websocket layer: - _handle_connect() now rejects connections without a valid JWT in multiuser mode (previously fell through to user_id="system"), so anonymous clients can no longer subscribe to queue rooms and observe other users' activity. In single-user mode it still accepts as system admin. - _handle_sub_queue() no longer silently falls back to the system user for an unknown sid in multiuser mode; it refuses the subscription. - QueueItemStatusChangedEvent and BatchEnqueuedEvent are now routed to user:{user_id} + admin rooms instead of the full queue room. Both events carry unsanitized user_id, batch_id, origin, destination, session_id, and error metadata and must not be broadcast. - BatchEnqueuedEvent gains a user_id field; emit_batch_enqueued and enqueue_batch thread it through. New TestWebSocketAuth suite covers connect accept/reject for both modes, sub_queue refusal, and private routing of the queue item and batch events (plus a QueueClearedEvent sanity check). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): verify user record on websocket connect A deleted or deactivated user with an unexpired JWT could still open a websocket and subscribe to queue rooms. Now _handle_connect() checks the backing user record (exists + is_active) in multiuser mode, mirroring the REST auth path in auth_dependencies.py. Fails closed if the user service is unavailable. Tests: added deleted-user and inactive-user rejection tests; updated valid-token test to create the user in the database first. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): close bulk download cross-user exfiltration path Backend: - POST /download now validates image read access (per-image) and board read access (per-board) before queuing the download. - GET /download/{name} is intentionally unauthenticated because the browser triggers it via <a download> which cannot carry Authorization headers. Access control relies on POST-time checks, UUID filename unguessability, private socket event routing, and single-fetch deletion. - Added _assert_board_read_access() helper to images router. - Threaded user_id through bulk download handler, base class, event emission, and BulkDownloadEventBase so events carry the initiator. - Bulk download service now tracks download ownership via _download_owners dict (cleaned up on delete). - Socket bulk_download room subscription restricted to authenticated sockets in multiuser mode. - Added error-catching in FastAPIEventService._dispatch_from_queue to prevent silent event dispatch failures. Frontend: - Fixed pre-existing race condition where the "Preparing Download" toast from the POST response overwrote the "Ready to Download" toast from the socket event (background task completes in ~17ms, so the socket event can arrive before Redux processes the HTTP response). Toast IDs are now distinct: "preparing:{name}" vs "{name}". - bulk_download_complete/error handlers now dismiss the preparing toast. Tests (8 new): - Bulk download by image names rejected for non-owner (403) - Bulk download by image names allowed for owner (202) - Bulk download from private board rejected (403) - Bulk download from shared board allowed (202) - Admin can bulk download any images (202) - Bulk download events carry user_id - Bulk download event emitted to download room - GET /download unauthenticated returns 404 for unknown files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): enforce board visibility on image listing endpoints GET /api/v1/images?board_id=... and GET /api/v1/images/names?board_id=... passed board_id directly to the SQL layer without checking board visibility. The SQL only applied user_id filtering for board_id="none" (uncategorized images), so any authenticated user who knew a private board ID could enumerate its images. Both endpoints now call _assert_board_read_access() before querying, returning 403 unless the caller is the board owner, an admin, or the board is Shared/Public. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(backend): ruff * fix(multiuser): require image ownership when adding images to boards add_image_to_board and add_images_to_board only checked write access to the destination board, never verifying that the caller owned the source image. An attacker could add a victim's image to their own board, then exploit the board-ownership fallback in _assert_image_owner to gain delete/patch/star/unstar rights on the image. Both endpoints now call _assert_image_direct_owner which requires direct image ownership (image_records.user_id) or admin — board ownership is intentionally not sufficient, preventing the escalation chain. Also fixed a pre-existing bug where HTTPException from the inner loop in add_images_to_board was caught by the outer except-Exception and returned as 500 instead of propagating the correct status code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(backend): ruff * fix(multiuser): validate image access in recall parameter resolution The recall endpoint loaded image files and ran ControlNet preprocessors on any image_name supplied in control_layers or ip_adapters without checking that the caller could read the image. An attacker who knew another user's image UUID could extract dimensions and, for supported preprocessors, mint a derived processed image they could then fetch. Added _assert_recall_image_access() which validates read access for every image referenced in the request before any resolution or processing occurs. Access is granted to the image owner, admins, or when the image sits on a Shared/Public board. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): require admin auth on model install job endpoints list_model_installs, get_model_install_job, pause, resume, restart_failed, and restart_file were unauthenticated — any caller who could reach the API could view sensitive install job fields (source, local_path, error_traceback) and interfere with installation state. All six endpoints now require AdminUserOrDefault, consistent with the neighboring cancel and prune routes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): close bulk download exfiltration and additional review findings Bulk download capability token exfiltration: - Socket events now route to user:{user_id} + admin rooms instead of the shared 'default' room (the earlier toast race that blocked this approach was fixed in a prior commit). - GET /download/{name} re-requires CurrentUserOrDefault and enforces ownership via get_owner(). - Frontend download handler replaced <a download> (which cannot carry auth headers) with fetch() + Authorization header + programmatic blob download. Additional fixes from reviewer tests: - Public boards now grant write access in _assert_board_write_access and mutation rights in _assert_image_owner (BoardVisibility.Public). - Uncategorized image listing (GET /boards/none/image_names) now filters to the caller's images only, preventing cross-user enumeration. - board_images router uses board_image_records.get_board_for_image() instead of images.get_dto() to avoid dependency on image_files service. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): add user_id scoping to workflow SQL mutations Defense-in-depth: the route layer already checks ownership before calling update/delete/update_is_public/update_opened_at, but the SQL statements did not include AND user_id = ?, so a bypass of the route check would allow cross-user mutations. All four methods now accept an optional user_id parameter. When provided, the SQL WHERE clause is scoped to that user. The route layer passes current_user.user_id for non-admin callers and None for admins. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(multiuser): allow non-owner uploads to public boards upload_image() blocked non-owner uploads even to public boards. The board write check now allows uploads when board_visibility is Public, consistent with the public-board semantics in _assert_board_write_access and _assert_image_owner. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
1 parent b42274a commit 33ec16d

79 files changed

Lines changed: 4798 additions & 390 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

invokeai/app/api/routers/auth.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class SetupStatusResponse(BaseModel):
8080
setup_required: bool = Field(description="Whether initial setup is required")
8181
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
8282
strict_password_checking: bool = Field(description="Whether strict password requirements are enforced")
83+
admin_email: str | None = Field(default=None, description="Email of the first active admin user, if any")
8384

8485

8586
@auth_router.get("/status", response_model=SetupStatusResponse)
@@ -94,15 +95,25 @@ async def get_setup_status() -> SetupStatusResponse:
9495
# If multiuser is disabled, setup is never required
9596
if not config.multiuser:
9697
return SetupStatusResponse(
97-
setup_required=False, multiuser_enabled=False, strict_password_checking=config.strict_password_checking
98+
setup_required=False,
99+
multiuser_enabled=False,
100+
strict_password_checking=config.strict_password_checking,
101+
admin_email=None,
98102
)
99103

100104
# In multiuser mode, check if an admin exists
101105
user_service = ApiDependencies.invoker.services.users
102106
setup_required = not user_service.has_admin()
103107

108+
# Only expose admin_email during initial setup to avoid leaking
109+
# administrator identity on public deployments.
110+
admin_email = user_service.get_admin_email() if setup_required else None
111+
104112
return SetupStatusResponse(
105-
setup_required=setup_required, multiuser_enabled=True, strict_password_checking=config.strict_password_checking
113+
setup_required=setup_required,
114+
multiuser_enabled=True,
115+
strict_password_checking=config.strict_password_checking,
116+
admin_email=admin_email,
106117
)
107118

108119

invokeai/app/api/routers/board_images.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,53 @@
11
from fastapi import Body, HTTPException
22
from fastapi.routing import APIRouter
33

4+
from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
45
from invokeai.app.api.dependencies import ApiDependencies
56
from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult
67

78
board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
89

910

11+
def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
12+
"""Raise 403 if the current user may not mutate the given board.
13+
14+
Write access is granted when ANY of these hold:
15+
- The user is an admin.
16+
- The user owns the board.
17+
- The board visibility is Public (public boards accept contributions from any user).
18+
"""
19+
from invokeai.app.services.board_records.board_records_common import BoardVisibility
20+
21+
try:
22+
board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
23+
except Exception:
24+
raise HTTPException(status_code=404, detail="Board not found")
25+
if current_user.is_admin:
26+
return
27+
if board.user_id == current_user.user_id:
28+
return
29+
if board.board_visibility == BoardVisibility.Public:
30+
return
31+
raise HTTPException(status_code=403, detail="Not authorized to modify this board")
32+
33+
34+
def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
35+
"""Raise 403 if the current user is not the direct owner of the image.
36+
37+
This is intentionally stricter than _assert_image_owner in images.py:
38+
board ownership is NOT sufficient here. Allowing a user to add someone
39+
else's image to their own board would grant them mutation rights via the
40+
board-ownership fallback in _assert_image_owner, escalating read access
41+
into write access.
42+
"""
43+
if current_user.is_admin:
44+
return
45+
owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
46+
if owner is not None and owner == current_user.user_id:
47+
return
48+
raise HTTPException(status_code=403, detail="Not authorized to move this image")
49+
50+
1051
@board_images_router.post(
1152
"/",
1253
operation_id="add_image_to_board",
@@ -17,14 +58,17 @@
1758
response_model=AddImagesToBoardResult,
1859
)
1960
async def add_image_to_board(
61+
current_user: CurrentUserOrDefault,
2062
board_id: str = Body(description="The id of the board to add to"),
2163
image_name: str = Body(description="The name of the image to add"),
2264
) -> AddImagesToBoardResult:
2365
"""Creates a board_image"""
66+
_assert_board_write_access(board_id, current_user)
67+
_assert_image_direct_owner(image_name, current_user)
2468
try:
2569
added_images: set[str] = set()
2670
affected_boards: set[str] = set()
27-
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
71+
old_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
2872
ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
2973
added_images.add(image_name)
3074
affected_boards.add(board_id)
@@ -48,13 +92,16 @@ async def add_image_to_board(
4892
response_model=RemoveImagesFromBoardResult,
4993
)
5094
async def remove_image_from_board(
95+
current_user: CurrentUserOrDefault,
5196
image_name: str = Body(description="The name of the image to remove", embed=True),
5297
) -> RemoveImagesFromBoardResult:
5398
"""Removes an image from its board, if it had one"""
5499
try:
100+
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
101+
if old_board_id != "none":
102+
_assert_board_write_access(old_board_id, current_user)
55103
removed_images: set[str] = set()
56104
affected_boards: set[str] = set()
57-
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
58105
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
59106
removed_images.add(image_name)
60107
affected_boards.add("none")
@@ -64,6 +111,8 @@ async def remove_image_from_board(
64111
affected_boards=list(affected_boards),
65112
)
66113

114+
except HTTPException:
115+
raise
67116
except Exception:
68117
raise HTTPException(status_code=500, detail="Failed to remove image from board")
69118

@@ -78,16 +127,21 @@ async def remove_image_from_board(
78127
response_model=AddImagesToBoardResult,
79128
)
80129
async def add_images_to_board(
130+
current_user: CurrentUserOrDefault,
81131
board_id: str = Body(description="The id of the board to add to"),
82132
image_names: list[str] = Body(description="The names of the images to add", embed=True),
83133
) -> AddImagesToBoardResult:
84134
"""Adds a list of images to a board"""
135+
_assert_board_write_access(board_id, current_user)
85136
try:
86137
added_images: set[str] = set()
87138
affected_boards: set[str] = set()
88139
for image_name in image_names:
89140
try:
90-
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
141+
_assert_image_direct_owner(image_name, current_user)
142+
old_board_id = (
143+
ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
144+
)
91145
ApiDependencies.invoker.services.board_images.add_image_to_board(
92146
board_id=board_id,
93147
image_name=image_name,
@@ -96,12 +150,16 @@ async def add_images_to_board(
96150
affected_boards.add(board_id)
97151
affected_boards.add(old_board_id)
98152

153+
except HTTPException:
154+
raise
99155
except Exception:
100156
pass
101157
return AddImagesToBoardResult(
102158
added_images=list(added_images),
103159
affected_boards=list(affected_boards),
104160
)
161+
except HTTPException:
162+
raise
105163
except Exception:
106164
raise HTTPException(status_code=500, detail="Failed to add images to board")
107165

@@ -116,6 +174,7 @@ async def add_images_to_board(
116174
response_model=RemoveImagesFromBoardResult,
117175
)
118176
async def remove_images_from_board(
177+
current_user: CurrentUserOrDefault,
119178
image_names: list[str] = Body(description="The names of the images to remove", embed=True),
120179
) -> RemoveImagesFromBoardResult:
121180
"""Removes a list of images from their board, if they had one"""
@@ -125,15 +184,21 @@ async def remove_images_from_board(
125184
for image_name in image_names:
126185
try:
127186
old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
187+
if old_board_id != "none":
188+
_assert_board_write_access(old_board_id, current_user)
128189
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
129190
removed_images.add(image_name)
130191
affected_boards.add("none")
131192
affected_boards.add(old_board_id)
193+
except HTTPException:
194+
raise
132195
except Exception:
133196
pass
134197
return RemoveImagesFromBoardResult(
135198
removed_images=list(removed_images),
136199
affected_boards=list(affected_boards),
137200
)
201+
except HTTPException:
202+
raise
138203
except Exception:
139204
raise HTTPException(status_code=500, detail="Failed to remove images from board")

invokeai/app/api/routers/boards.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
88
from invokeai.app.api.dependencies import ApiDependencies
9-
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
9+
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility
1010
from invokeai.app.services.boards.boards_common import BoardDTO
1111
from invokeai.app.services.image_records.image_records_common import ImageCategory
1212
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
@@ -56,7 +56,14 @@ async def get_board(
5656
except Exception:
5757
raise HTTPException(status_code=404, detail="Board not found")
5858

59-
if not current_user.is_admin and result.user_id != current_user.user_id:
59+
# Admins can access any board.
60+
# Owners can access their own boards.
61+
# Shared and public boards are visible to all authenticated users.
62+
if (
63+
not current_user.is_admin
64+
and result.user_id != current_user.user_id
65+
and result.board_visibility == BoardVisibility.Private
66+
):
6067
raise HTTPException(status_code=403, detail="Not authorized to access this board")
6168

6269
return result
@@ -188,12 +195,27 @@ async def list_all_board_image_names(
188195
except Exception:
189196
raise HTTPException(status_code=404, detail="Board not found")
190197

191-
if not current_user.is_admin and board.user_id != current_user.user_id:
198+
if (
199+
not current_user.is_admin
200+
and board.user_id != current_user.user_id
201+
and board.board_visibility == BoardVisibility.Private
202+
):
192203
raise HTTPException(status_code=403, detail="Not authorized to access this board")
193204

194205
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
195206
board_id,
196207
categories,
197208
is_intermediate,
198209
)
210+
211+
# For uncategorized images (board_id="none"), filter to only the caller's
212+
# images so that one user cannot enumerate another's uncategorized images.
213+
# Admin users can see all uncategorized images.
214+
if board_id == "none" and not current_user.is_admin:
215+
image_names = [
216+
name
217+
for name in image_names
218+
if ApiDependencies.invoker.services.image_records.get_user_id(name) == current_user.user_id
219+
]
220+
199221
return image_names

0 commit comments

Comments
 (0)